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/CHANGELOG.md b/CHANGELOG.md index 938097fc7b5..1a01e43b677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2.6.x-dev + +* 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: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) +* GraphQL: Support for field name conversion (serialized name) (#3455, #3516) +* 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) + ## 2.5.5 * Filter: Improve the RangeFilter query in case the values are equals using the between operator #3488 @@ -22,6 +34,7 @@ * HTTP: Location header is only set on POST with a 201 or between 300 and 400 #3497 * GraphQL: Do not allow empty cursor values on `before` or `after` #3360 * Bump versions of Swagger UI, GraphiQL and GraphQL Playground #3510 +>>>>>>> 2.5 ## 2.5.4 @@ -75,7 +88,7 @@ * Allow to not declare GET item operation * Add support for the Accept-Patch header -* Make the the `maximum_items_per_page` attribute consistent with other attributes controlling pagination +* Make the 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 an 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 c4505483f95..a58587c9e6f 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 @@ -69,8 +64,6 @@ 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. * Squash your commits into one commit (see the next chapter). -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`) and integration tests (`behat`). diff --git a/behat.yml.dist b/behat.yml.dist index 10c4b191383..785d444063c 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -17,6 +17,7 @@ default: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' filters: @@ -56,6 +57,7 @@ postgres: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' filters: @@ -81,6 +83,7 @@ mongodb: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' filters: @@ -124,6 +127,7 @@ default-coverage: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'CoverageContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' @@ -149,6 +153,7 @@ mongodb-coverage: jsonApiSchemaFile: '%paths.base%/tests/Fixtures/JsonSchema/jsonapi.json' - 'JsonHalContext': schemaFile: '%paths.base%/tests/Fixtures/JsonHal/jsonhal.json' + - 'MercureContext' - 'CoverageContext' - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' diff --git a/composer.json b/composer.json index 3fe004f96ae..f03b5adf9e7 100644 --- a/composer.json +++ b/composer.json @@ -133,7 +133,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.5.x-dev" + "dev-master": "2.6.x-dev" }, "symfony": { "require": "^3.4 || ^4.0 || ^5.0" 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/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 3e519762e76..6ae697eeb29 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -37,6 +37,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; 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; @@ -93,6 +94,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; @@ -1502,6 +1504,27 @@ 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(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -1879,4 +1902,12 @@ private function buildConvertedRelated() { return $this->isOrm() ? new ConvertedRelated() : new ConvertedRelatedDocument(); } + + /** + * @return DummyMercure|DummyMercureDocument + */ + private function buildDummyMercure() + { + return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); + } } diff --git a/features/bootstrap/JsonApiContext.php b/features/bootstrap/JsonApiContext.php index ff89b039704..bff928a7075 100644 --- a/features/bootstrap/JsonApiContext.php +++ b/features/bootstrap/JsonApiContext.php @@ -107,6 +107,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/features/bootstrap/MercureContext.php b/features/bootstrap/MercureContext.php new file mode 100644 index 00000000000..ace847fb7bd --- /dev/null +++ b/features/bootstrap/MercureContext.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); + +use ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher; +use Behat\Gherkin\Node\PyStringNode; +use Behat\Symfony2Extension\Context\KernelAwareContext; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Mercure\Update; + +/** + * Context for Mercure. + * + * @author Alan Poulain + */ +final class MercureContext implements KernelAwareContext +{ + private $kernel; + + public function setKernel(KernelInterface $kernel): void + { + $this->kernel = $kernel; + } + + /** + * @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 DummyMercurePublisher $publisher */ + $publisher = $this->kernel->getContainer()->get('mercure.hub.default.publisher'); + + /** @var Update $sentUpdate */ + foreach ($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/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index 857e0c70718..e33b7515c47 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, 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/introspection.feature b/features/graphql/introspection.feature index 0c093266985..62c34ae86e2 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..0baa2ce0b10 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 "400" 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..93285285dec 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -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/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/phpstan.neon.dist b/phpstan.neon.dist index ac2c74e41e2..b2352a8425b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -38,9 +38,6 @@ parameters: - message: '#Variable \$positionPm might not be defined\.#' path: src/Util/ClassInfoTrait.php - - - message: "#Cannot access offset 'node' on bool\\.#" - path: src/GraphQl/Serializer/SerializerContextBuilder.php - '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy<(\\?[a-zA-Z0-9_]+)+>::\$[a-zA-Z0-9_]+#' - message: '#Call to an undefined method Doctrine\\Persistence\\ObjectManager::getConnection\(\)#' @@ -82,6 +79,9 @@ 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: '#Call to method PHPUnit\\Framework\\Assert::assertSame\(\) with array\(.+\) and array\(.+\) will always evaluate to false\.#' + path: tests/Util/SortTraitTest.php - message: '#Binary operation "\+" between (float\|int\|)?string and 0 results in an error\.#' path: src/Bridge/Doctrine/Common/Filter/RangeFilterTrait.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 718ae4e64a8..9bfb7ed5601 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ $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.'); @@ -66,26 +72,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'); } } @@ -95,16 +112,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(); @@ -113,17 +130,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; } @@ -137,7 +154,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".'); } - $value = $this->expressionLanguage->evaluate($value, ['object' => $entity]); + $value = $this->expressionLanguage->evaluate($value, ['object' => $object]); } if (true === $value) { @@ -148,39 +165,67 @@ private function storeEntityToPublish($entity, string $property): void throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of targets or a valid expression, "%s" given.', $resourceClass, \gettype($value))); } - 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), ]] = $value; return; } - $this->{$property}[$entity] = $value; + $this->{$property}[$object] = $value; } /** - * @param object $entity + * @param object $object */ - private function publishUpdate($entity, array $targets): void + private function publishUpdate($object, array $targets, 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 = $entity->iri; + // and I'm not a fond of this approach. + $iri = $object->iri; /** @var string $data */ - $data = json_encode(['@id' => $entity->id]); + $data = json_encode(['@id' => $object->id]); } else { - $resourceClass = $this->getObjectClass($entity); + $resourceClass = $this->getObjectClass($object); $context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); - $iri = $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($entity, key($this->formats), $context); + $iri = $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL); + $data = $this->serializer->serialize($object, key($this->formats), $context); + } + + $updates = array_merge([new Update($iri, $data, $targets)], $this->getGraphQlSubscriptionUpdates($object, $targets, $type)); + + foreach ($updates as $update) { + $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); + } + } + + /** + * @param object $object + * + * @return Update[] + */ + private function getGraphQlSubscriptionUpdates($object, array $targets, string $type): array + { + if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { + return []; + } + + $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object); + + $updates = []; + foreach ($payloads as [$subscriptionId, $data]) { + $updates[] = new Update( + $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId), + (string) (new JsonResponse($data))->getContent(), + $targets + ); } - $update = new Update($iri, $data, $targets); - $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); + return $updates; } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php index da4dac68ba5..b8820439d8a 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\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; @@ -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 d335235aff4..68302cf142d 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php @@ -54,7 +54,7 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); - if (null !== $defaultOrder) { + 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 75215bbeb61..80399ffbc20 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\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; @@ -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 9dcc53f35e1..12db2dda163 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\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; @@ -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 2c490deb456..beceaf0a22d 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\Common\Persistence\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; @@ -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, function ($in, $result) use ($previousAssociationProperty) { return $in + array_map(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..0cc5a94d125 100644 --- a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php @@ -52,7 +52,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); - if (null !== $defaultOrder) { + 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 da0b8f3b505..9157107f97d 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -33,6 +33,7 @@ * Filter the collection by given properties. * * @author Kévin Dunglas + * @final */ class SearchFilter extends AbstractContextAwareFilter implements SearchFilterInterface { @@ -92,12 +93,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $caseSensitive = true; $metadata = $this->getNestedMetadata($resourceClass, $associations); - if ($metadata->hasField($field)) { - if ('id' === $field) { - $values = array_map([$this, 'getIdFromValue'], $values); - } + $doctrineTypeField = $this->getDoctrineFieldType($property, $resourceClass); + $values = array_map([$this, 'getIdFromValue'], $values); - if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { + if ($metadata->hasField($field)) { + if (!$this->hasValidValues($values, $doctrineTypeField)) { $this->logger->notice('Invalid filter ignored', [ 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), ]); @@ -114,7 +114,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB } if (1 === \count($values)) { - $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive); + $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive, $doctrineTypeField); return; } @@ -140,9 +140,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } - $values = array_map([$this, 'getIdFromValue'], $values); $associationFieldIdentifier = 'id'; - $doctrineTypeField = $this->getDoctrineFieldType($property, $resourceClass); if (null !== $this->identifiersExtractor) { $associationResourceClass = $metadata->getAssociationTargetClass($field); @@ -171,11 +169,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (1 === \count($values)) { $queryBuilder ->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter)) - ->setParameter($valueParameter, $values[0]); + ->setParameter($valueParameter, $values[0], $doctrineTypeField); } else { $queryBuilder ->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter)) - ->setParameter($valueParameter, $values); + ->setParameter($valueParameter, $values, $doctrineTypeField); } } @@ -184,8 +182,15 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * * @throws InvalidArgumentException If strategy does not exist */ - protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive) + protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive/*, string $fieldType = null*/) { + $fieldType = null; + if (8 === \func_num_args()) { + $fieldType = func_get_arg(7); + } else { + @trigger_error(sprintf('Method "%s()" will have a 8th `string $fieldType` argument in version 3.0. Not defining it is deprecated since 2.6.', __METHOD__), E_USER_DEPRECATED); + } + $wrapCase = $this->createWrapCase($caseSensitive); $valueParameter = $queryNameGenerator->generateParameterName($field); @@ -194,27 +199,27 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild case self::STRATEGY_EXACT: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' = '.$wrapCase(':%s'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); + ->setParameter($valueParameter, $value, $fieldType); break; case self::STRATEGY_PARTIAL: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); + ->setParameter($valueParameter, $value, $fieldType); break; case self::STRATEGY_START: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); + ->setParameter($valueParameter, $value, $fieldType); break; case self::STRATEGY_END: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); + ->setParameter($valueParameter, $value, $fieldType); 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); + ->setParameter($valueParameter, $value, $fieldType); break; default: throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); diff --git a/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php b/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php index 1f326321aec..48bcff7e3d0 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/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index ead557e482c..68323417c8a 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -23,6 +23,8 @@ 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; @@ -165,25 +167,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']); @@ -191,6 +194,47 @@ private function registerCommonConfiguration(ContainerBuilder $container, array if ($config['name_converter']) { $container->setAlias('api_platform.name_converter', $config['name_converter']); } + $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' => []]; + $rootLevelOptions = [ + 'description', + 'iri', + 'item_operations', + 'collection_operations', + 'graphql', + ]; + + foreach ($defaults as $option => $value) { + if (\in_array($option, $rootLevelOptions, true)) { + $normalizedDefaults[$option] = $value; + } else { + $normalizedDefaults['attributes'][$option] = $value; + } + } + + return $normalizedDefaults; } private function registerMetadataConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void @@ -507,7 +551,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'); } @@ -530,6 +575,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']) { @@ -564,6 +615,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 diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 7288762d9c7..c3485902af5 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; @@ -33,6 +34,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. @@ -128,13 +130,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('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('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('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('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('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('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('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() @@ -179,6 +209,8 @@ public function getConfigTreeBuilder() 'jsonld' => ['mime_types' => ['application/ld+json']], ]); + $this->addDefaultsSection($rootNode); + return $treeBuilder; } @@ -311,10 +343,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('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('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('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('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.') @@ -329,6 +374,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() @@ -494,4 +543,28 @@ private function addFormatSection(ArrayNodeDefinition $rootNode, string $key, ar ->end() ->end(); } + + private function addDefaultsSection(ArrayNodeDefinition $rootNode): void + { + $nameConverter = new CamelCaseToSnakeCaseNameConverter(); + $defaultsNode = $rootNode->children()->arrayNode('defaults'); + + $defaultsNode + ->ignoreExtraKeys() + ->beforeNormalization() + ->always(function (array $defaults) use ($nameConverter) { + $normalizedDefaults = []; + foreach ($defaults as $option => $value) { + $option = $nameConverter->normalize($option); + $normalizedDefaults[$option] = $value; + } + + return $normalizedDefaults; + }); + + foreach (ApiResource::CONFIGURABLE_DEFAULTS as $attribute) { + $snakeCased = $nameConverter->normalize($attribute); + $defaultsNode->children()->variableNode($snakeCased); + } + } } diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index dbc7cf741e5..f475bf67a36 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -115,7 +115,7 @@ null - false + 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 fa300e4a700..d129fd7019b 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..6868de419b7 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,11 +16,17 @@ %api_platform.formats% + + + + Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead. + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 07617218bf5..a5ca2c709ea 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,7 @@ + %kernel.debug% %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% @@ -217,12 +229,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..a42c976c90b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -40,7 +40,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index 410a327b43d..7d232706bc1 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -41,7 +41,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index b40b0ed1c2d..3912ad1e1b4 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/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/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/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 830a735fe23..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; @@ -44,6 +45,7 @@ final class Client implements HttpClientInterface 'body' => '', 'json' => null, 'base_uri' => 'http://example.com', + 'extra' => [], ]; private $kernelBrowser; @@ -121,7 +123,7 @@ public function request(string $method, string $url, array $options = []): Respo 'url' => $resolvedUrl, 'primary_port' => 'http:' === $url['scheme'] ? 80 : 443, ]; - $this->kernelBrowser->request($method, $resolvedUrl, [], [], $server, $options['body'] ?? null); + $this->kernelBrowser->request($method, $resolvedUrl, $options['extra']['parameters'] ?? [], $options['extra']['files'] ?? [], $server, $options['body'] ?? null); return $this->response = new Response($this->kernelBrowser->getResponse(), $this->kernelBrowser->getInternalResponse(), $info); } @@ -166,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/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..faebcad48e8 100644 --- a/src/Bridge/Symfony/Messenger/DataPersister.php +++ b/src/Bridge/Symfony/Messenger/DataPersister.php @@ -75,7 +75,10 @@ public function supports($data, array $context = []): bool */ public function persist($data, array $context = []) { - $envelope = $this->dispatch($data); + $envelope = $this->dispatch( + (new Envelope($data)) + ->with(new ContextStamp($context)) + ); $handledStamp = $envelope->last(HandledStamp::class); if (!$handledStamp instanceof HandledStamp) { 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..6564b955456 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, 'paginationType', 'cursor', true); + } + /** * Is the classic or partial pagination enabled? */ 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 c6583a02c44..00000000000 --- a/src/Filter/QueryParameterValidateListener.php +++ /dev/null @@ -1,104 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -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])) { - $keyName = array_keys($matches[$rootName])[0]; - - $queryParameter = $request->query->get($rootName); - - return \is_array($queryParameter) && isset($queryParameter[$keyName]); - } - - return null !== $request->query->get($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 846c5019cf4..ab3cdccfada 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -17,16 +17,18 @@ use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface; use GraphQL\Error\Debug; 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 @@ -35,17 +37,19 @@ final class EntrypointAction private $executor; private $graphiQlAction; private $graphQlPlaygroundAction; + private $normalizer; 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, 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->debug = $debug ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : false; $this->graphiqlEnabled = $graphiqlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; @@ -54,29 +58,28 @@ 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); 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, $operation) + ->setErrorFormatter([$this->normalizer, 'normalize']); + } catch (\Exception $exception) { + $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, null, null, $exception)])) + ->setErrorFormatter([$this->normalizer, 'normalize']); } return new JsonResponse($executionResult->toArray($this->debug)); @@ -207,11 +210,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, 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/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 bfe4bde7ac1..f74e4657b10 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -17,12 +17,13 @@ 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\ClassInfoTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Read stage of GraphQL resolvers. @@ -34,6 +35,7 @@ final class ReadStage implements ReadStageInterface { use ClassInfoTrait; + use IdentifierTrait; private $resourceMetadataFactory; private $iriConverter; @@ -63,22 +65,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,11 +91,13 @@ 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])) { $rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; $subresourceCollection = $this->getSubresource($rootClass, $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; @@ -105,17 +106,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 */ 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..061cfb3199e --- /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\HttpException; +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 HttpException */ + $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 HttpException; + } +} 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..d62986ef561 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.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\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 +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var ValidationException */ + $validationException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $validationException->getMessage(); + $error['extensions']['status'] = Response::HTTP_BAD_REQUEST; + $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 64d0d47a75c..c073edaeebe 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 = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', 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 = (string) $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', '', 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', '', 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', '', 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', ''), $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', ''), $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); @@ -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 b6f09a8a8d7..db7cec25557 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: @@ -72,7 +72,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; } @@ -96,7 +96,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) { @@ -113,7 +113,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/HttpCache/EventListener/AddHeadersListener.php b/src/HttpCache/EventListener/AddHeadersListener.php index 9ea49d7f951..fec4997f8ee 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 @@ -83,5 +87,13 @@ public function onKernelResponse(ResponseEvent $event): void if (null !== $this->public && !$response->headers->hasCacheControlDirective('public')) { $this->public ? $response->setPublic() : $response->setPrivate(); } + + 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 5f317639314..35bb7405ede 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -25,13 +25,41 @@ final class VarnishPurger implements PurgerInterface { private $clients; + private $maxHeaderLength; /** * @param ClientInterface[] $clients */ - public function __construct(array $clients) + public function __construct(array $clients, int $maxHeaderLength = 7500) { $this->clients = $clients; + $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; } /** @@ -43,6 +71,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(function ($iri) { return sprintf('(^|\,)%s($|\,)', preg_quote($iri)); diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index c7ac9d13f6c..bece5e16396 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); } /** @@ -83,8 +88,9 @@ 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 ? ['@id' => $this->iriConverter->getIriFromItem($obj)] : $this->normalizer->normalize($obj, $format, $context); } $paginated = null; 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/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 ca869d67f0b..ade96fe723c 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/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index aca10e346d7..197050fdd55 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/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index b27aad82c24..ac582f45be1 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -153,6 +153,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 +167,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 +191,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(); @@ -215,7 +233,9 @@ 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])) { diff --git a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php index 9f882eb9cac..9362aa5d6e2 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php @@ -112,12 +112,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..a847c13e8ef 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php @@ -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 } } @@ -87,7 +87,7 @@ public function create(string $resourceClass, array $options = []): 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..63f64bf49da 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 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 d941e0e4918..c04364b5b9b 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, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->reader = $reader; $this->decorated = $decorated; + $this->defaults = $defaults + ['attributes' => []]; } /** @@ -78,16 +80,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/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/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/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 2e513af8ff3..3b4d4446bbe 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -24,6 +24,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; @@ -55,13 +56,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) { @@ -83,6 +85,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->allowPlainIdentifiers = $allowPlainIdentifiers; $this->dataTransformers = $dataTransformers; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceAccessChecker = $resourceAccessChecker; } /** @@ -169,7 +172,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; @@ -223,8 +230,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} * @@ -238,19 +244,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) { @@ -295,6 +290,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} */ @@ -339,6 +352,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} */ @@ -494,7 +526,6 @@ protected function createRelationSerializationContext(string $resourceClass, arr /** * {@inheritdoc} * - * @throws NoSuchPropertyException * @throws UnexpectedValueException * @throws LogicException */ @@ -503,6 +534,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) { 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/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..aa550d42e4f 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,7 @@ 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, diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index a4d38a1a79a..26d7954ce86 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -18,6 +18,8 @@ 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; @@ -37,7 +39,7 @@ */ class PublishMercureUpdatesListenerTest extends TestCase { - public function testPublishUpdate() + public function testPublishUpdate(): void { $toInsert = new Dummy(); $toInsert->setId(1); @@ -115,7 +117,75 @@ public function testPublishUpdate() $this->assertSame([[], [], [], ['foo', 'bar']], $targets); } - public function testNoPublisher() + public function testPublishGraphQlUpdates(): void + { + $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 = []; + $targets = []; + $data = []; + $publisher = function (Update $update) use (&$topics, &$targets, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $targets[] = $update->getTargets(); + $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([[], []], $targets); + $this->assertSame(['2', '["data"]'], $data); + } + + public function testNoPublisher(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('A message bus or a publisher must be provided.'); @@ -131,7 +201,7 @@ public function testNoPublisher() ); } - 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 targets or a valid expression, "integer" given.'); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php index 001e4d997a8..2b972521c1a 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 Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\ObjectRepository; @@ -33,13 +35,27 @@ */ class CollectionDataProviderTest extends TestCase { + 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); @@ -48,13 +64,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')); } @@ -69,15 +118,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')); } @@ -91,21 +139,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 f6b4f383628..9532392f42a 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php @@ -42,24 +42,28 @@ class PaginationExtensionTest extends TestCase { 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', @@ -71,6 +75,7 @@ public function testApplyToCollection() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -78,14 +83,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, @@ -98,6 +102,7 @@ public function testApplyToCollectionWithItemPerPageZero() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -108,14 +113,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, @@ -129,6 +133,7 @@ public function testApplyToCollectionWithItemPerPageZeroAndPage2() $extension = new PaginationExtension( $this->prophesize(ManagerRegistry::class)->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -139,14 +144,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, @@ -160,6 +164,7 @@ public function testApplyToCollectionWithItemPerPageLessThan0() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -167,14 +172,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', @@ -187,6 +191,7 @@ public function testApplyToCollectionWithItemPerPageTooHigh() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -194,14 +199,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); @@ -211,6 +215,7 @@ public function testApplyToCollectionWithGraphql() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -218,14 +223,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); @@ -244,6 +248,7 @@ public function testApplyToCollectionWithGraphqlAndCountContext() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -251,9 +256,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); @@ -263,6 +267,7 @@ public function testApplyToCollectionNoFilters() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -270,9 +275,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, @@ -285,6 +289,7 @@ public function testApplyToCollectionPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -292,9 +297,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, @@ -307,6 +311,7 @@ public function testApplyToCollectionGraphQlPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -314,14 +319,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, @@ -335,6 +339,7 @@ public function testApplyToCollectionWithMaximumItemsPerPage() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -342,14 +347,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')); @@ -357,9 +362,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, @@ -368,6 +372,7 @@ public function testSupportsResultClientNotAllowedToPaginate() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['pagination' => true]])); @@ -375,9 +380,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, @@ -385,6 +389,7 @@ public function testSupportsResultPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['enabled' => false]])); @@ -392,9 +397,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, @@ -402,6 +406,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'])); @@ -409,8 +414,7 @@ public function testSupportsResultGraphQlPaginationDisabled() public function testGetResult() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -420,6 +424,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([ [ @@ -432,7 +438,7 @@ public function testGetResult() ]); $aggregationBuilderProphecy = $this->prophesize(Builder::class); - $aggregationBuilderProphecy->execute()->willReturn($iteratorProphecy->reveal()); + $aggregationBuilderProphecy->execute([])->willReturn($iteratorProphecy->reveal()); $aggregationBuilderProphecy->getPipeline()->willReturn([ [ '$facet' => [ @@ -449,6 +455,7 @@ public function testGetResult() $paginationExtension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); @@ -458,6 +465,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 2f5cee9f086..bba7ade6290 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 Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\ObjectManager; @@ -42,6 +44,18 @@ */ class ItemDataProviderTest extends TestCase { + 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]; @@ -57,7 +71,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(Match::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, [ @@ -65,10 +112,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)); } @@ -88,7 +142,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, [ @@ -97,11 +151,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)); } @@ -126,7 +182,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'); } @@ -151,7 +207,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)); } @@ -167,7 +223,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')); } @@ -190,7 +246,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 301843efa60..5745364d34f 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; @@ -45,6 +47,18 @@ */ class SubresourceDataProviderTest extends TestCase { + 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); @@ -94,7 +108,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], []); } @@ -126,7 +140,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()); @@ -137,16 +151,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]; @@ -171,7 +187,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(); @@ -198,7 +214,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); @@ -220,7 +236,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); @@ -231,15 +247,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(Match::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(Match::class); + $rMatch->equals(1)->shouldBeCalled(); + $rMatch->field('id')->shouldBeCalled()->willReturn($rMatch); + $previousRMatch = $this->prophesize(Match::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(Match::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 @@ -274,16 +389,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]; @@ -321,12 +438,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); @@ -334,7 +453,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]; @@ -357,7 +476,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], []); } @@ -371,7 +490,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], []); } @@ -401,7 +520,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); @@ -411,9 +530,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 5b5e6f418e5..0fe0dc146e7 100644 --- a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php +++ b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Test\DoctrineOrmFilterTestCase; use ApiPlatform\Core\Tests\Bridge\Doctrine\Common\Filter\SearchFilterTestTrait; +use ApiPlatform\Core\Tests\Fixtures\ExtendingSearchFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; @@ -363,6 +364,18 @@ private function doTestApplyWithAnotherAlias(bool $request) $expectedDql = sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name = :name_p1', 'somealias', Dummy::class); $this->assertEquals($expectedDql, $queryBuilder->getQuery()->getDQL()); + $this->assertTrue($queryBuilder->getParameter('name_p1')->typeWasSpecified()); + } + + /** + * @group legacy + * @expectedDeprecation The "ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter" class is considered final. It may change without further notice as of its next major version. You should not extend it from "ApiPlatform\Core\Tests\Fixtures\ExtendingSearchFilter". + * + * @expectedDeprecation Method "ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter::addWhereByStrategy()" will have a 8th `string $fieldType` argument in version 3.0. Not defining it is deprecated since 2.6. + */ + public function testDeprecateAddWhereByStrategyWithoutType() + { + new ExtendingSearchFilter($this->repository->createQueryBuilder($this->alias)); } public function provideApplyTestData(): array diff --git a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php index a12e29033b2..321f106c1b1 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 Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\Common\Persistence\Mapping\ClassMetadata; use Doctrine\Common\Persistence\ObjectManager; @@ -73,6 +74,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()); @@ -88,6 +90,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(); @@ -98,6 +125,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/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 026f8ce9c7a..9daf7bdd61d 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -55,6 +55,8 @@ 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; @@ -144,6 +146,9 @@ class ApiPlatformExtensionTest extends TestCase 'doctrine_mongodb_odm' => [ 'enabled' => false, ], + 'defaults' => [ + 'attributes' => [], + ], ]]; private $extension; @@ -339,6 +344,7 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.action.graphql_playground', 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(); @@ -364,7 +370,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(); @@ -589,7 +603,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(); @@ -602,6 +616,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; @@ -641,6 +656,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(); @@ -692,6 +708,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' => []])->shouldBeCalled(); $config = self::DEFAULT_CONFIG; $config['api_platform']['elasticsearch'] = [ @@ -841,6 +858,8 @@ 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.defaults' => ['attributes' => []], 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, ]; @@ -913,12 +932,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', @@ -929,6 +946,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', @@ -1075,6 +1093,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); @@ -1115,6 +1141,7 @@ 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.defaults' => ['attributes' => []], ]; if ($hasSwagger) { @@ -1138,7 +1165,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', @@ -1170,6 +1197,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', @@ -1178,6 +1206,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', @@ -1189,7 +1221,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', @@ -1207,6 +1243,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', @@ -1224,11 +1263,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', @@ -1310,6 +1351,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo BooleanFilter::class => 'api_platform.doctrine.orm.boolean_filter', NumericFilter::class => 'api_platform.doctrine.orm.numeric_filter', ExistsFilter::class => 'api_platform.doctrine.orm.exists_filter', + 'api_platform.doctrine.listener.mercure.publish' => 'api_platform.doctrine.orm.listener.mercure.publish', GraphQlSerializerContextBuilderInterface::class => 'api_platform.graphql.serializer.context_builder', ]; @@ -1346,7 +1388,10 @@ 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 () {}); return $containerBuilderProphecy; } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index e3ded4b4c73..57510a68ca1 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -182,6 +182,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => false, 'varnish_urls' => [], 'request_options' => [], + 'max_header_length' => 7500, ], 'etag' => true, 'max_age' => null, @@ -289,7 +290,7 @@ public function invalidHttpStatusCodeValueProvider() public function testExceptionToStatusConfigWithInvalidHttpStatusCodeValue($invalidHttpStatusCodeValue) { $this->expectException(InvalidTypeException::class); - $this->expectExceptionMessageRegExp('/Invalid type for path "api_platform\\.exception_to_status\\.Exception". Expected "?int"?, but got .+\\./'); + $this->expectExceptionMessageRegExp('/Invalid type for path "api_platform\\.exception_to_status\\.Exception". Expected "?int"?, but got "?.+"?\./'); $this->processor->processConfiguration($this->configuration, [ 'api_platform' => [ 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 2ff2d0dba05..781ab6277b0 100644 --- a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php +++ b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php @@ -13,6 +13,7 @@ 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\Exception\ResourceClassNotFoundException; @@ -56,7 +57,9 @@ public function testPersist() $dummy = new Dummy(); $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()); $this->assertSame($dummy, $dataPersister->persist($dummy)); @@ -67,6 +70,7 @@ public function testRemove() $dummy = new Dummy(); $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(); @@ -80,7 +84,9 @@ public function testHandle() $dummy = new Dummy(); $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))->with(new HandledStamp($dummy, 'DummyHandler::__invoke')))->shouldBeCalled(); $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 3ae3a29a27e..1e989499ecd 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; @@ -20,6 +23,7 @@ use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity; 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; @@ -48,7 +52,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); @@ -66,7 +74,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); @@ -83,7 +95,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); @@ -100,7 +116,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); @@ -117,7 +137,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); @@ -134,7 +158,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); @@ -149,8 +177,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); @@ -186,11 +219,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 bee2588fcde..c5f02a64feb 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 PHPUnit\Framework\TestCase; @@ -74,26 +75,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/Filter/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php similarity index 69% rename from tests/Filter/QueryParameterValidateListenerTest.php rename to tests/EventListener/QueryParameterValidateListenerTest.php index 47e6efacfb2..8a0b53f8361 100644 --- a/tests/Filter/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -11,23 +11,22 @@ 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 PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; class QueryParameterValidateListenerTest extends TestCase { private $testedInstance; - private $filterLocatorProphecy; + private $queryParameterValidor; /** * unsafe method should not use filter validations. @@ -60,8 +59,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()) @@ -81,24 +79,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()); @@ -112,32 +96,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()) @@ -156,11 +129,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/ExtendingSearchFilter.php b/tests/Fixtures/ExtendingSearchFilter.php new file mode 100644 index 00000000000..337518feb9e --- /dev/null +++ b/tests/Fixtures/ExtendingSearchFilter.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; + +use ApiPlatform\Core\Bridge\Doctrine\Common\Filter\SearchFilterInterface; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator; +use Doctrine\ORM\QueryBuilder; + +class ExtendingSearchFilter extends SearchFilter implements SearchFilterInterface +{ + public function __construct(QueryBuilder $queryBuilder) + { + parent::addWhereByStrategy(self::STRATEGY_EXACT, $queryBuilder, new QueryNameGenerator(), 'o', 'name', 'test', false); + } +} diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index 2ddc9f878e8..a25c1bff186 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..ea64c6babe7 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCar.php +++ b/tests/Fixtures/TestBundle/Document/DummyCar.php @@ -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/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index 2cb5ef90f57..90c9de2aec8 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"={"paginationType"="page"} + * } + * ) * @ODM\Document */ class FooDummy 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/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index a6bb99575fe..ad0660f65a9 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -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/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/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index a06658a5707..1ec0a15a8cb 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"={"paginationType"="page"} + * } + * ) * @ORM\Entity */ class FooDummy 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/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 e4c861cbb4a..caf23a48d8c 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -58,14 +58,6 @@ api_platform: nesting_separator: __ name_converter: 'app.name_converter' enable_fos_user: true - collection: - order_parameter_name: 'order' - order: 'ASC' - pagination: - client_enabled: true - client_items_per_page: true - client_partial: true - items_per_page: 3 exception_to_status: Symfony\Component\Serializer\Exception\ExceptionInterface: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST ApiPlatform\Core\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST @@ -73,10 +65,17 @@ 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 parameters: container.autowiring.strict_mode: true @@ -141,10 +140,38 @@ 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' 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'] @@ -207,6 +234,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 diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/tests/GraphQl/Action/EntrypointActionTest.php index 74ffd74aad0..9da2d7edc7f 100644 --- a/tests/GraphQl/Action/EntrypointActionTest.php +++ b/tests/GraphQl/Action/EntrypointActionTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\GraphQl\Action\GraphiQlAction; use ApiPlatform\Core\GraphQl\Action\GraphQlPlaygroundAction; 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 GraphQL\Executor\ExecutionResult; use GraphQL\Type\Schema; @@ -27,6 +29,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; /** @@ -37,7 +40,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'); @@ -53,7 +56,7 @@ 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->setRequestFormat('json'); @@ -62,7 +65,7 @@ public function testGetAction() $this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request)); } - public function testPostRawAction() + public function testPostRawAction(): void { $request = new Request(['variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery'); $request->setFormat('graphql', 'application/graphql'); @@ -73,7 +76,7 @@ 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->setMethod('POST'); @@ -86,7 +89,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) { @@ -144,82 +147,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"}', 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"}', '{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"}', '{"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"}', '{"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"}', '{"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->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 @@ -228,8 +231,14 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En $schemaBuilderProphecy = $this->prophesize(SchemaBuilderInterface::class); $schemaBuilderProphecy->getSchema()->willReturn($schema->reveal()); + $normalizer = new Serializer([ + new HttpExceptionNormalizer(), + new ErrorNormalizer(), + ]); + $executionResultProphecy = $this->prophesize(ExecutionResult::class); $executionResultProphecy->toArray(false)->willReturn(['GraphQL']); + $executionResultProphecy->setErrorFormatter([$normalizer, 'normalize'])->willReturn($executionResultProphecy); $executorProphecy = $this->prophesize(ExecutorInterface::class); $executorProphecy->executeQuery(Argument::is($schema->reveal()), 'graphqlQuery', null, null, $variables, 'graphqlOperationName')->willReturn($executionResultProphecy->reveal()); @@ -239,6 +248,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, false, true, true, 'graphiql'); } } diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php index 960306d3832..0fe3d62762c 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php @@ -74,7 +74,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); @@ -138,7 +138,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); @@ -157,7 +157,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 8f9f5ae178f..da160d8c2b8 100644 --- a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php @@ -24,7 +24,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -82,7 +81,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'; @@ -150,7 +149,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); @@ -169,7 +168,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'; @@ -210,7 +209,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'; @@ -250,7 +249,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'; @@ -302,7 +301,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'; @@ -321,7 +320,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 1093daf904d..8d91e2774b5 100644 --- a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php @@ -21,7 +21,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -74,7 +73,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); @@ -124,7 +123,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); @@ -143,12 +142,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); @@ -162,12 +161,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); @@ -181,7 +180,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); @@ -222,7 +221,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); @@ -236,7 +235,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 d63e39e2fbd..d4138bac209 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -22,9 +22,9 @@ use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @author Alan Poulain @@ -101,6 +101,7 @@ public function testApplyItem(?string $identifier, $item, bool $throwNotFound, $ $context = [ 'is_collection' => false, 'is_mutation' => false, + 'is_subscription' => false, 'args' => ['id' => $identifier], 'info' => $info, ]; @@ -132,18 +133,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, ]; @@ -168,15 +170,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], ]; } @@ -193,6 +197,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, diff --git a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php index 301963c836a..1595e10e438 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php @@ -17,10 +17,10 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -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 @@ -107,7 +107,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 a49207679a2..e3d431a2c72 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php @@ -17,10 +17,10 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -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 @@ -88,7 +88,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 9ee58d2a369..aaa330805db 100644 --- a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php @@ -20,7 +20,6 @@ use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -65,10 +64,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]], ]; } @@ -100,10 +100,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']], ]; } @@ -117,6 +118,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(), ]; @@ -140,15 +142,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]]], ]; } @@ -157,7 +159,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]; @@ -165,7 +167,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 7b064b75ef3..904bcd50213 100644 --- a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php @@ -18,7 +18,6 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; 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; @@ -92,7 +91,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..fadf5b421a7 --- /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, 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, 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..2050dc8f772 --- /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, 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, 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..37d10665279 --- /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, null, $exception); + + $normalizedError = $this->validationExceptionNormalizer->normalize($error); + $this->assertSame($exceptionMessage, $normalizedError['message']); + $this->assertSame(400, $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, null, $exception); + + $this->assertTrue($this->validationExceptionNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/tests/GraphQl/Serializer/ItemNormalizerTest.php index 6c82eec7183..13363bee76a 100644 --- a/tests/GraphQl/Serializer/ItemNormalizerTest.php +++ b/tests/GraphQl/Serializer/ItemNormalizerTest.php @@ -123,6 +123,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 fc6e40b5ccf..b4a3e4d6072 100644 --- a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php +++ b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php @@ -19,6 +19,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; /** * @author Alan Poulain @@ -36,24 +38,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([ @@ -70,51 +80,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, @@ -122,33 +167,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 014113d45ed..ee4001044db 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -38,6 +38,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; /** * @author Alan Poulain @@ -71,6 +72,9 @@ class FieldsBuilderTest extends TestCase /** @var ObjectProphecy */ private $itemMutationResolverFactoryProphecy; + /** @var ObjectProphecy */ + private $itemSubscriptionResolverFactoryProphecy; + /** @var ObjectProphecy */ private $filterLocatorProphecy; @@ -91,8 +95,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 @@ -124,7 +134,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); @@ -139,11 +149,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())], ], @@ -203,10 +213,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); @@ -232,12 +242,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(), @@ -325,21 +335,41 @@ public function collectionQueryFieldsProvider(): array ], ], ], + 'collection with page-based pagination enabled' => ['resourceClass', (new ResourceMetadata('ShortName', null, null, null, null, ['paginationType' => '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' => new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]]), + 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), + ], + 'resolve' => $resolver, + 'deprecationReason' => '', + ], + ], + ], ]; } /** * @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); @@ -349,15 +379,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, @@ -369,22 +399,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' => '', + ], + ], + 'resolve' => $mutationResolver, + 'deprecationReason' => '', + ], + ], + ], + ]; + } + + /** + * @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' => $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' => GraphQLType::listOf($graphqlType), + 'type' => $inputGraphqlType, 'description' => null, 'args' => [], 'resolve' => null, 'deprecationReason' => '', ], ], - 'resolve' => $collectionResolver, + 'resolve' => $subscriptionResolver, 'deprecationReason' => '', ], ], @@ -395,30 +490,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(), [ @@ -427,7 +529,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()), @@ -448,12 +550,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' => '', + ], + ], + $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()), @@ -473,7 +594,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()), @@ -494,7 +615,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()), @@ -527,7 +648,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()), @@ -539,7 +660,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()), @@ -555,7 +676,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()), @@ -570,17 +691,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' => '', + ], + ], + ], + '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(), ], @@ -590,7 +746,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 919561aa07f..d6bea8a7445 100644 --- a/tests/GraphQl/Type/SchemaBuilderTest.php +++ b/tests/GraphQl/Type/SchemaBuilderTest.php @@ -65,7 +65,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'; @@ -81,6 +81,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); @@ -88,6 +90,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')); } @@ -101,7 +104,7 @@ public function schemaProvider(): array 'fields' => [ 'node' => ['node_fields'], ], - ]), null, + ]), null, null, ], 'item query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['item_query' => []]), new ObjectType([ @@ -110,7 +113,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'query' => ['query_fields'], ], - ]), null, + ]), null, null, ], 'collection query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['collection_query' => []]), new ObjectType([ @@ -119,7 +122,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([ @@ -128,7 +131,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([ @@ -137,7 +140,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([ @@ -152,6 +155,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 b76a9ce7844..1721122fcda 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 GraphQL\Type\Definition\InputObjectType; @@ -46,6 +48,9 @@ class TypeBuilderTest extends TestCase /** @var ObjectProphecy */ private $fieldsBuilderLocatorProphecy; + /** @var ObjectProphecy */ + private $resourceMetadataFactoryProphecy; + /** @var TypeBuilder */ private $typeBuilder; @@ -58,7 +63,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 @@ -70,7 +81,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); @@ -78,7 +89,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'](); } @@ -93,7 +104,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); @@ -101,7 +112,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'](); } @@ -122,7 +133,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); } @@ -159,7 +170,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); @@ -169,7 +180,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'](); } @@ -184,7 +195,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); @@ -194,7 +205,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()); @@ -210,7 +221,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); @@ -241,7 +252,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); @@ -267,7 +278,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'](); } @@ -281,7 +292,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); @@ -289,7 +300,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'](); } @@ -316,15 +423,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, + ['paginationType' => '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); @@ -370,6 +486,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, + ['paginationType' => '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 69cb0eae469..af34e81465f 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/tests/GraphQl/Type/TypeConverterTest.php @@ -63,7 +63,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); } @@ -92,7 +92,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); } @@ -103,7 +103,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); } @@ -115,9 +115,9 @@ public function testConvertTypeResource(): void $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/HttpCache/EventListener/AddHeadersListenerTest.php b/tests/HttpCache/EventListener/AddHeadersListenerTest.php index 2e5e2af634a..9206b96dc31 100644 --- a/tests/HttpCache/EventListener/AddHeadersListenerTest.php +++ b/tests/HttpCache/EventListener/AddHeadersListenerTest.php @@ -102,11 +102,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->reveal()); $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()); } @@ -126,11 +126,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->reveal()); $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()); } @@ -143,14 +143,14 @@ public function testSetHeadersFromResourceMetadata() $event->getRequest()->willReturn($request)->shouldBeCalled(); $event->getResponse()->willReturn($response)->shouldBeCalled(); - $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->reveal()); - $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 82591fd5099..0460a86df1c 100644 --- a/tests/HttpCache/VarnishPurgerTest.php +++ b/tests/HttpCache/VarnishPurgerTest.php @@ -33,9 +33,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/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index 2ef97fd951b..c98cc1a1cd1 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -328,4 +328,50 @@ 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(), [CollectionNormalizer::IRI_ONLY => true]); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'collection_operation_name' => 'get', + 'operation_type' => OperationType::COLLECTION, + 'resource_class' => Foo::class, + ]); + + $this->assertSame([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/foos/1'], + ['@id' => '/foos/3'], + ], + 'hydra:totalItems' => 2, + ], $actual); + } } diff --git a/tests/Hydra/Serializer/EntrypointNormalizerTest.php b/tests/Hydra/Serializer/EntrypointNormalizerTest.php index 5205965be21..36752e018a1 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 PHPUnit\Framework\TestCase; /** @@ -47,14 +48,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(); @@ -67,6 +70,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/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 5368fd39c22..79d65a1b11c 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -95,7 +95,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/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 2496e7880aa..0788fd0cbde 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php @@ -24,7 +24,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 c732b84095b..9660ad1841b 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php @@ -23,7 +23,7 @@ use PHPUnit\Framework\TestCase; /** - * @author Antoine Bluchet + * @group legacy */ class InheritedPropertyNameCollectionFactoryTest extends TestCase { diff --git a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 39aafee3365..42b0e0f900d 100644 --- a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -30,9 +30,6 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -/** - * @author Teoh Han Hui - */ class SerializerPropertyMetadataFactoryTest extends TestCase { public function testConstruct() @@ -137,7 +134,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 27835cd7468..c53dfa048c6 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php @@ -46,6 +46,40 @@ public function testCreate($reader, $decorated, string $expectedShortName, strin $this->assertEquals(['foo' => 'bar'], $metadata->getGraphql()); } + 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, + ], + ]; + + $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')); + } + public function testCreateWithoutAttributes() { $annotation = new ApiResource([]); diff --git a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php index 4b16a9cafb2..28aea8d4b52 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; /** @@ -286,4 +288,52 @@ 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, + ], + ]; + $resourceConfiguration = [ + Dummy::class => [ + 'shortName' => null, + 'description' => null, + 'subresourceOperations' => null, + 'itemOperations' => ['get', 'delete'], + 'attributes' => [ + 'pagination_maximum_items_per_page' => 10, + ], + ], + ]; + + $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')); + } } 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/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 8930b519a64..c78fe49d388 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -25,6 +25,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 +34,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 Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -110,7 +112,7 @@ public function testSupportNormalizationAndSupportDenormalization() [], [], null, - false, + null, ]); $this->assertTrue($normalizer->supportsNormalization($dummy)); @@ -177,7 +179,7 @@ public function testNormalize() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -196,6 +198,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(); @@ -253,7 +314,7 @@ public function testNormalizeReadableLinks() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -315,7 +376,7 @@ public function testDenormalize() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -365,7 +426,7 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass $serializerProphecy->willImplement(DenormalizerInterface::class); $serializerProphecy->denormalize($data, DummyForAdditionalFieldsInput::class, 'json', $cleanedContext)->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()); @@ -424,7 +485,7 @@ public function testDenormalizeWritableLinks() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -486,7 +547,7 @@ public function testBadRelationType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -544,7 +605,7 @@ public function testInnerDocumentNotAllowed() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -589,7 +650,7 @@ public function testBadType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -631,7 +692,7 @@ public function testTypeChecksCanBeDisabled() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -677,7 +738,7 @@ public function testJsonAllowIntAsFloat() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -752,7 +813,7 @@ public function testDenormalizeBadKeyType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -794,7 +855,7 @@ public function testNullable() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -805,7 +866,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'); @@ -851,7 +915,7 @@ public function testChildInheritedProperty() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -903,7 +967,7 @@ public function testDenormalizeRelationWithPlainId() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -966,7 +1030,7 @@ public function testDenormalizeRelationWithPlainIdNotFound() [], [], null, - true, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1024,7 +1088,7 @@ public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNo [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1111,7 +1175,7 @@ public function testNormalizationWithDataTransformer() [], [$dataTransformerProphecy->reveal(), $secondDataTransformerProphecy->reveal()], $resourceMetadataFactoryProphecy->reveal(), - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index bf14f0422bf..61cfe4d4d35 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 PHPUnit\Framework\TestCase; @@ -2775,7 +2776,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'schema' => [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], ], @@ -2800,7 +2801,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 201 => [ 'description' => 'Dummy resource created', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], 400 => [ @@ -2816,7 +2817,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The new Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:b4f76c1a44965bd401aa23bb37618acc', + '$ref' => '#/definitions/Dummy:InputDto', ], ], ], @@ -2840,7 +2841,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource response', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], 404 => [ @@ -2866,7 +2867,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The updated Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:b4f76c1a44965bd401aa23bb37618acc', + '$ref' => '#/definitions/Dummy:InputDto', ], ], ], @@ -2874,7 +2875,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource updated', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy:OutputDto', ], ], 400 => [ @@ -2888,7 +2889,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ], ]), 'definitions' => new \ArrayObject([ - 'Dummy:300dcd476cef011532fb0ca7683395d7' => new \ArrayObject([ + 'Dummy:OutputDto' => new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', 'externalDocs' => [ @@ -2906,7 +2907,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ]), ], ]), - 'Dummy:b4f76c1a44965bd401aa23bb37618acc' => new \ArrayObject([ + 'Dummy:InputDto' => new \ArrayObject([ 'type' => 'object', 'description' => 'This is a dummy.', 'externalDocs' => [ @@ -2929,4 +2930,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 5df7edd28cb..846f638dafe 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -110,10 +110,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())); @@ -374,6 +374,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/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); + } +}