diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d75159ed5..347c9fc9911 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,11 @@ jobs: - JsonSchema - OpenApi - Metadata + - Elasticsearch + - HttpCache + - RamseyUuid + - GraphQl + - Serializer fail-fast: false steps: - name: Checkout diff --git a/.github/workflows/guides.yaml b/.github/workflows/guides.yaml index 3f910047eef..238617d9999 100644 --- a/.github/workflows/guides.yaml +++ b/.github/workflows/guides.yaml @@ -42,7 +42,7 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Install project dependencies working-directory: docs - run: composer install --no-interaction --no-progress --ansi + run: composer install --no-interaction --no-progress --ansi && composer require webonyx/graphql-php - name: Test guides working-directory: docs env: @@ -53,10 +53,5 @@ jobs: for d in guides/*.php; do rm -f var/data.db echo "Testing guide $d" - pdg-phpunit $d - code=$? - if [[ $code -ne 0 ]]; then - break - fi + pdg-phpunit $d || exit 1 done - exit $code diff --git a/.gitignore b/.gitignore index ef76a6ce6cf..57d0a18c303 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ *.log /.php-cs-fixer.php /.php-cs-fixer.cache -/.phpunit.result.cache +.phpunit.result.cache /.phpunit.cache/ /build/ -/composer.lock +composer.lock /composer.phar /phpstan.neon /phpunit.xml @@ -16,3 +16,4 @@ /tests/Metadata/Extractor/Adapter/*.yaml /vendor/ /Dockerfile + diff --git a/CHANGELOG.md b/CHANGELOG.md index 3412f66ca54..235a8a397b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v3.1.14 + +### Bug fixes + +* [146f55330](https://github.com/api-platform/core/commit/146f55330e3df8301ac84345b69a25cdfb908b27) fix(metadata): operation NotExposed status to 404 (#5717) +* [4dcfc16c3](https://github.com/api-platform/core/commit/4dcfc16c38ab4c371a37a7d92d2f2f205de31f89) fix(symfony): perf regression with Symfony 6.3 (#5721) +* [4f9626f42](https://github.com/api-platform/core/commit/4f9626f42b75a5fd1f9d681c80ad6c4ee56318fe) fix(serializer): use data if no uri_variables provided (#5743) +* [7bb92a52f](https://github.com/api-platform/core/commit/7bb92a52f5c6e02705547408281eba93f73b588e) fix(doctrine): use stateOptions only within doctrine context (#5726) +* [83dbfbff1](https://github.com/api-platform/core/commit/83dbfbff1717dabba7ce9e814d0bdb556b49fcb8) fix(metadata): generated NotExposed operation should inherit resource options (#5722) +* [ccad63683](https://github.com/api-platform/core/commit/ccad6368303d341f37eff0317cc8e433504c460f) Revert "fix: search on nested sub-entity that doesn't use "id" as its ORM identifier (#5623)" (#5744) +* [e2745855b](https://github.com/api-platform/core/commit/e2745855be4986d361626d1b853e45cde229d3d8) fix(openapi): model Example, Header and Reference (#5716) +* [ebf03104f](https://github.com/api-platform/core/commit/ebf03104fcbffc5af74d78c3e9b14d02d7527214) fix(jsonld): skolem uri template may have a _format (#5729) + ## v3.1.13 ### Bug fixes diff --git a/README.md b/README.md index d1081305bba..9892efac041 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,15 @@ # API Platform Core -API Platform Core is an easy to use and powerful system to create [hypermedia-driven REST](https://en.wikipedia.org/wiki/HATEOAS) and [GraphQL](https://graphql.org/) APIs. +API Platform Core is an easy-to-use and powerful system to create [hypermedia-driven REST](https://en.wikipedia.org/wiki/HATEOAS) and [GraphQL](https://graphql.org/) APIs. It is a component of the [API Platform framework](https://api-platform.com) and it can be integrated with [the Symfony framework](https://symfony.com) using the bundle distributed with the library. -It natively supports popular open formats including [JSON for Linked Data (JSON-LD)](https://json-ld.org), [Hydra Core Vocabulary](https://www.hydra-cg.com), [OpenAPI v2 (formerly Swagger) and v3](https://www.openapis.org), [HAL](https://tools.ietf.org/html/draft-kelly-json-hal-08) and [Problem Details](https://tools.ietf.org/html/rfc7807). +It natively supports popular open formats including [JSON for Linked Data (JSON-LD)](https://json-ld.org), [Hydra Core Vocabulary](https://www.hydra-cg.com), [OpenAPI v2 (formerly Swagger) and v3](https://www.openapis.org), [JSON:API](https://jsonapi.org/), [HAL](https://tools.ietf.org/html/draft-kelly-json-hal-08) and [Problem Details](https://tools.ietf.org/html/rfc7807). -Build a working and fully-featured CRUD API in minutes. Leverage the awesome features of the tool to develop complex and -high performance API-first projects. Extend or override everything you want. +Build a working and fully-featured web API in minutes. Leverage the awesome features of the tool to develop complex and +high-performance API-first projects. Extend or override everything you want. [![GitHub Actions](https://github.com/api-platform/core/workflows/CI/badge.svg?branch=main)](https://github.com/api-platform/core/actions?query=workflow%3ACI+branch%3Amain) -[![Codecov](https://codecov.io/gh/api-platform/core/branch/main/graph/badge.svg)](https://codecov.io/gh/api-platform/core/branch/main) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/api-platform/core/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/api-platform/core/?branch=main) ## Documentation diff --git a/docs/adr/0005-refactor-state-management.md b/docs/adr/0005-refactor-state-management.md new file mode 100644 index 00000000000..a4196044dab --- /dev/null +++ b/docs/adr/0005-refactor-state-management.md @@ -0,0 +1,54 @@ +# Refactor State Management + +* Status: proposed +* Deciders: @dunglas, @soyuka, @alanpoulain + +## Context and Problem Statement + +Since version 2.2, we support both REST and GraphQL API styles out of the box. +This leads to some code duplication to extract states from request bodies (write requests), +and to generate resource representations from states retrieved from the data source. + +The REST susbystem uses [Symfony-specific kernel event listeners](https://api-platform.com/docs/core/events/#built-in-event-listeners) +while the GraphQL subsystem relies on [an ad-hoc resolver system](https://api-platform.com/docs/core/graphql/#workflow-of-the-resolvers). + +Also, while it's possible [to use API Platform as a standalone PHP library](https://api-platform.com/docs/core/bootstrap/), +this requires writing boilerplate code doing mostly what is done in the Symfony-specific listeners. +As we're working on integrating API Platform with Laravel, the Laravel package will contain similar code to the one in the Symfony +event listeners too. + +As Martin Fowler writes in the _Patterns of Enterprise Application Architecture_: + +> There’s just one controller, so you can easily enhance its behavior at +runtime with decorators [Gang of Four](https://en.wikipedia.org/wiki/Design_Patterns). You can have decorators for +authentication, character encoding, internationalization, and so forth, +and add them using a configuration file or even while the server is +running. ([Alur et al.](http://www.corej2eepatterns.com/InterceptingFilter.htm) describe this approach in detail under the name +Intercepting Filter.) + +For API Platform 3, we refactored the whole metadata susbsytem to be more flexible, powerful, and covering more use cases. +This led to the refactoring of the two main interfaces allowing to plug a data source in API Platform: the state provider and the state processor interfaces. + +Leveraging these new interfaces, it should be possible to simplify the code base and to remove most code duplication by transforming most of the code currently +stored in the kernel event listeners and in the GraphQL resolvers in dedicated state processors and state providers. + +## Decision Outcome + +1. Move the logic currently stored in `ReadListener`, `DeserializeListener`, `DenyAccessListener`, `ValidateListener`, `WriteListener` and `SerializeListener` in classes implementing the `StateProcessorInterface` or `StateProviderInterface`. These classes will implement the decorator pattern. All classes will be composed to create a chain. The classes containing Symfony-specific and/or Doctrine-specific logic will then be able to be easily replaced by other implementations (Laravel, PSR-7, super-globals...). +2. Remove the corresponding listeners. +3. Replace `PlaceholderController` by a controller calling the main `StateProcessor` and `StateProvider` objects directly. +4. Remove the GraphQL resolvers, call the main `StateProcessor` and StateProvider objects directly. + +Consenquently, transforming the raw request body (e.g. the raw JSON document) to a PHP data structure will be the responsibility of a processor. +The default one will use the Symfony Serializer component to do so, but this will give the opportunity to the user to replace this class by one using another Serializer if necessary. +This will also allow the user to access the raw body if necessary, and will enable a whole class of optimizations, extra validations (e.g. validating a raw JSON string against a JSON Schema) etc. +Similarly, transforming PHP data structures into strings to be stored in response bodies will now be the responsibility of a state provider. + +This will reduce the weigth of the code base and improve the whole design of API Platform. + +This new design will also replace what we currently call [the "DTO" feature](https://api-platform.com/docs/core/dto/): a "data transformer" will now be just another state provider or processor. + +Finally, the `resumable()` method will be removed from these interfaces. The decorator pattern allows, by ordering the composed objects, to achieve the same result with more flexibility. + +To help using API Platform without Symfony, we could provide factories building the correct chain of data providers and persisters without relying on the Symfony Dependency Injection Component. + diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index db85e6f335a..69dad54adbb 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -1025,15 +1025,6 @@ Feature: Search filter on collections And the response should be in JSON And the JSON node "hydra:totalItems" should be equal to 1 - @!mongodb - @createSchema - Scenario: Search on nested sub-entity that doesn't use "id" as its ORM identifier - Given there is a dummy entity with a sub entity with id "stringId" and name "someName" - When I send a "GET" request to "/dummy_with_subresource?subEntity=/dummy_subresource/stringId" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - @!mongodb @createSchema Scenario: Custom search filters can use Doctrine Expressions as join conditions diff --git a/features/hal/problem.feature b/features/hal/problem.feature index 9ee65614a96..5c969902d54 100644 --- a/features/hal/problem.feature +++ b/features/hal/problem.feature @@ -16,9 +16,10 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json) And the JSON should be equal to: """ { - "type": "https://tools.ietf.org/html/rfc2616#section-10", + "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", "title": "An error occurred", "detail": "name: This value should not be blank.", + "status": "422", "violations": [ { "propertyPath": "name", @@ -44,7 +45,7 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json) Then the response status code should be 400 And the response should be in JSON And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "type" should be equal to "https://tools.ietf.org/html/rfc2616#section-10" + And the JSON node "type" should be equal to "/errors/400" And the JSON node "title" should be equal to "An error occurred" And the JSON node "detail" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.' And the JSON node "trace" should exist diff --git a/features/main/patch.feature b/features/main/patch.feature index eeddbc5a1e0..8724a0196fc 100644 --- a/features/main/patch.feature +++ b/features/main/patch.feature @@ -58,3 +58,25 @@ Feature: Sending PATCH requets } } """ + + Scenario: Patch a relation with uri variables that are not `id` + When I add "Content-Type" header equal to "application/merge-patch+json" + And I send a "PATCH" request to "/betas/1" with body: + """ + { + "alpha": "/alphas/2" + } + """ + Then the response should be in JSON + And the response status code should be 200 + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Beta", + "@id": "/betas/1", + "@type": "Beta", + "betaId": 1, + "alpha": "/alphas/2" + } + """ diff --git a/features/main/union_intersect_types.feature b/features/main/union_intersect_types.feature new file mode 100644 index 00000000000..ba47388e554 --- /dev/null +++ b/features/main/union_intersect_types.feature @@ -0,0 +1,121 @@ +Feature: Union/Intersect types + + Scenario Outline: Create a resource with union type + When I add "Content-Type" header equal to "application/ld+json" + And I add "Accept" header equal to "application/ld+json" + And I send a "POST" request to "/issue-5452/books" with body: + """ + { + "number": , + "isbn": "978-3-16-148410-0" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@type": { + "type": "string", + "pattern": "^Book$" + }, + "@context": { + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@id": { + "type": "string", + "pattern": "^/.well-known/genid/.+$" + }, + "number": { + "type": "" + }, + "isbn": { + "type": "string", + "pattern": "^978-3-16-148410-0$" + } + }, + "required": [ + "@type", + "@context", + "@id", + "number", + "isbn" + ] + } + """ + Examples: + | number | type | + | "1" | string | + | 1 | integer | + + Scenario: Create a resource with valid intersect type + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/issue-5452/books" with body: + """ + { + "number": 1, + "isbn": "978-3-16-148410-0", + "author": "/issue-5452/authors/1" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@type": { + "type": "string", + "pattern": "^Book$" + }, + "@context": { + "type": "string", + "pattern": "^/contexts/Book$" + }, + "@id": { + "type": "string", + "pattern": "^/.well-known/genid/.+$" + }, + "number": { + "type": "integer" + }, + "isbn": { + "type": "string", + "pattern": "^978-3-16-148410-0$" + }, + "author": { + "type": "string", + "pattern": "^/issue-5452/authors/1$" + } + }, + "required": [ + "@type", + "@context", + "@id", + "number", + "isbn", + "author" + ] + } + """ + + Scenario: Create a resource with invalid intersect type + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/issue-5452/books" with body: + """ + { + "number": 1, + "isbn": "978-3-16-148410-0", + "library": "/issue-5452/libraries/1" + } + """ + Then the response status code should be 400 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON node "hydra:description" should be equal to 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.' diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index f3fee21b110..f90ff496394 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -10,7 +10,7 @@ Feature: Documentation support And the response should be in JSON And the header "Content-Type" should be equal to "application/json; charset=utf-8" # Context - And the JSON node "openapi" should be equal to "3.0.0" + And the JSON node "openapi" should be equal to "3.1.0" # Root properties And the JSON node "info.title" should be equal to "My Dummy API" And the JSON node "info.description" should contain "This is a test API." @@ -86,19 +86,19 @@ Feature: Documentation support { "default": "male", "example": "male", - "type": "string", + "type": ["string", "null"], "enum": [ "male", "female", null - ], - "nullable": true + ] } """ And the "playMode" property exists for the OpenAPI class "VideoGame" And the "playMode" property for the OpenAPI class "VideoGame" should be equal to: """ { + "owl:maxCardinality": 1, "type": "string", "format": "iri-reference" } @@ -238,8 +238,7 @@ Feature: Documentation support "type": "string" }, "property": { - "type": "string", - "nullable": true + "type": ["string", "null"] }, "required": { "type": "boolean" @@ -310,12 +309,15 @@ Feature: Documentation support And the "resourceRelated" property for the OpenAPI class "Resource" should be equal to: """ { - "readOnly":true, - "anyOf":[ + "owl:maxCardinality": 1, + "readOnly": true, + "anyOf": [ + { + "$ref": "#/components/schemas/ResourceRelated" + }, { - "$ref":"#/components/schemas/ResourceRelated" + "type": "null" } - ], - "nullable":true + ] } """ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a495fd2567d..513a144518b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -56,7 +56,7 @@ parameters: - message: '#expects ApiPlatform\\Metadata\\GraphQl\\Operation\|null, ApiPlatform\\Metadata\\Operation given#' paths: - - tests/GraphQl/Resolver/Factory/ + - src/GraphQl/Tests/Resolver/Factory/ - '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy<(\\?[a-zA-Z0-9_]+)+>::\$[a-zA-Z0-9_]+#' # https://github.com/willdurand/Negotiation/issues/89#issuecomment-513283286 - @@ -65,7 +65,7 @@ parameters: # https://github.com/phpstan/phpstan-symfony/issues/76 - message: '#Service "test" is not registered in the container\.#' - path: tests/GraphQl/Type/TypesContainerTest.php + path: src/GraphQl/Tests/Type/TypesContainerTest.php # Expected, due to optional interfaces - '#Method Symfony\\Component\\Serializer\\NameConverter\\NameConverterInterface::denormalize\(\) invoked with (2|3|4) parameters, 1 required\.#' diff --git a/src/Api/CompositeIdentifierParser.php b/src/Api/CompositeIdentifierParser.php index aed4bc8c094..e434e63df4e 100644 --- a/src/Api/CompositeIdentifierParser.php +++ b/src/Api/CompositeIdentifierParser.php @@ -17,6 +17,8 @@ * Normalizes a composite identifier. * * @author Antoine Bluchet + * + * @deprecated */ final class CompositeIdentifierParser { diff --git a/src/Api/FilterLocatorTrait.php b/src/Api/FilterLocatorTrait.php index ec65ff8722a..5119390bf3c 100644 --- a/src/Api/FilterLocatorTrait.php +++ b/src/Api/FilterLocatorTrait.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Api; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\FilterInterface; use Psr\Container\ContainerInterface; /** diff --git a/src/Api/IdentifiersExtractor.php b/src/Api/IdentifiersExtractor.php index f93c31e03f6..54d1987cce1 100644 --- a/src/Api/IdentifiersExtractor.php +++ b/src/Api/IdentifiersExtractor.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Api; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; @@ -28,6 +28,8 @@ /** * {@inheritdoc} * + * @deprecated use ApiPlatform\Metadata\IdentifiersExtractor instead + * * @author Antoine Bluchet */ final class IdentifiersExtractor implements IdentifiersExtractorInterface diff --git a/src/Api/IdentifiersExtractorInterface.php b/src/Api/IdentifiersExtractorInterface.php index 005c52dc89c..757d37e1171 100644 --- a/src/Api/IdentifiersExtractorInterface.php +++ b/src/Api/IdentifiersExtractorInterface.php @@ -13,20 +13,10 @@ namespace ApiPlatform\Api; -use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\Metadata\Operation; +class_exists(\ApiPlatform\Metadata\IdentifiersExtractorInterface::class); -/** - * Extracts identifiers for a given Resource according to the retrieved Metadata. - * - * @author Antoine Bluchet - */ -interface IdentifiersExtractorInterface -{ - /** - * Finds identifiers from an Item (object). - * - * @throws RuntimeException - */ - public function getIdentifiersFromItem(object $item, Operation $operation = null, array $context = []): array; +if (!class_exists(IdentifiersExtractorInterface::class)) { + interface IdentifiersExtractorInterface extends \ApiPlatform\Metadata\IdentifiersExtractorInterface + { + } } diff --git a/src/Api/IriConverterInterface.php b/src/Api/IriConverterInterface.php index 691d37dfbf5..59a798403ac 100644 --- a/src/Api/IriConverterInterface.php +++ b/src/Api/IriConverterInterface.php @@ -13,33 +13,10 @@ namespace ApiPlatform\Api; -use ApiPlatform\Exception\InvalidArgumentException; -use ApiPlatform\Exception\ItemNotFoundException; -use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\Metadata\Operation; +class_exists(\ApiPlatform\Metadata\IriConverterInterface::class); -/** - * Converts item and resources to IRI and vice versa. - * - * @author Kévin Dunglas - */ -interface IriConverterInterface -{ - /** - * Retrieves an item from its IRI. - * - * @throws InvalidArgumentException - * @throws ItemNotFoundException - */ - public function getResourceFromIri(string $iri, array $context = [], Operation $operation = null): object; - - /** - * Gets the IRI associated with the given item. - * - * @param object|class-string $resource - * - * @throws InvalidArgumentException - * @throws RuntimeException - */ - public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, Operation $operation = null, array $context = []): ?string; +if (!class_exists(IriConverterInterface::class)) { + interface IriConverterInterface extends \ApiPlatform\Metadata\IriConverterInterface + { + } } diff --git a/src/Api/ResourceClassResolverInterface.php b/src/Api/ResourceClassResolverInterface.php index 753238f2f14..0dd636478b9 100644 --- a/src/Api/ResourceClassResolverInterface.php +++ b/src/Api/ResourceClassResolverInterface.php @@ -13,6 +13,10 @@ namespace ApiPlatform\Api; -interface ResourceClassResolverInterface extends \ApiPlatform\Metadata\ResourceClassResolverInterface -{ +class_exists(\ApiPlatform\Metadata\ResourceClassResolverInterface::class); + +if (false) { // @phpstan-ignore-line + interface ResourceClassResolverInterface extends \ApiPlatform\Metadata\ResourceClassResolverInterface + { + } } diff --git a/src/Api/UriVariableTransformerInterface.php b/src/Api/UriVariableTransformerInterface.php index c3a0013244e..544e5c1c989 100644 --- a/src/Api/UriVariableTransformerInterface.php +++ b/src/Api/UriVariableTransformerInterface.php @@ -13,27 +13,6 @@ namespace ApiPlatform\Api; -use ApiPlatform\Exception\InvalidUriVariableException; - -interface UriVariableTransformerInterface +interface UriVariableTransformerInterface extends \ApiPlatform\Metadata\UriVariableTransformerInterface { - /** - * Transforms the value of a URI variable (identifier) to its type. - * - * @param mixed $value The URI variable value to transform - * @param array $types The guessed type behind the URI variable - * @param array $context Options available to the transformer - * - * @throws InvalidUriVariableException Occurs when the URI variable could not be transformed - */ - public function transform(mixed $value, array $types, array $context = []); - - /** - * Checks whether the value of a URI variable can be transformed to its type by this transformer. - * - * @param mixed $value The URI variable value to transform - * @param array $types The types to which the URI variable value should be transformed - * @param array $context Options available to the transformer - */ - public function supportsTransformation(mixed $value, array $types, array $context = []): bool; } diff --git a/src/Api/UriVariablesConverterInterface.php b/src/Api/UriVariablesConverterInterface.php index 917fc7b9de3..4ed3c921c68 100644 --- a/src/Api/UriVariablesConverterInterface.php +++ b/src/Api/UriVariablesConverterInterface.php @@ -13,24 +13,10 @@ namespace ApiPlatform\Api; -use ApiPlatform\Exception\InvalidIdentifierException; +class_exists(\ApiPlatform\Metadata\UriVariablesConverterInterface::class); -/** - * Identifier converter. - * - * @author Antoine Bluchet - */ -interface UriVariablesConverterInterface -{ - /** - * Takes an array of strings representing URI variables (identifiers) and transform their values to the expected type. - * - * @param array $data URI variables to convert to PHP values - * @param string $class The class to which the URI variables belong to - * - * @throws InvalidIdentifierException - * - * @return array Array indexed by identifiers properties with their values denormalized - */ - public function convert(array $data, string $class, array $context = []): array; +if (!class_exists(UriVariablesConverterInterface::class)) { + interface UriVariablesConverterInterface extends \ApiPlatform\Metadata\UriVariablesConverterInterface + { + } } diff --git a/src/Api/UrlGeneratorInterface.php b/src/Api/UrlGeneratorInterface.php index b6ee97cf0cd..91da13770da 100644 --- a/src/Api/UrlGeneratorInterface.php +++ b/src/Api/UrlGeneratorInterface.php @@ -13,72 +13,6 @@ namespace ApiPlatform\Api; -use Symfony\Component\Routing\Exception\InvalidParameterException; -use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; -use Symfony\Component\Routing\Exception\RouteNotFoundException; - -/** - * UrlGeneratorInterface is the interface that all URL generator classes must implement. - * - * This interface has been imported and adapted from the Symfony project. - * - * The constants in this interface define the different types of resource references that - * are declared in RFC 3986: http://tools.ietf.org/html/rfc3986 - * We are using the term "URL" instead of "URI" as this is more common in web applications - * and we do not need to distinguish them as the difference is mostly semantical and - * less technical. Generating URIs, i.e. representation-independent resource identifiers, - * is also possible. - * - * @author Fabien Potencier - * @author Tobias Schultze - * @copyright Fabien Potencier - */ -interface UrlGeneratorInterface +interface UrlGeneratorInterface extends \ApiPlatform\Metadata\UrlGeneratorInterface { - /** - * Generates an absolute URL, e.g. "http://example.com/dir/file". - */ - public const ABS_URL = 0; - - /** - * Generates an absolute path, e.g. "/dir/file". - */ - public const ABS_PATH = 1; - - /** - * Generates a relative path based on the current request path, e.g. "../parent-file". - * - * @see UrlGenerator::getRelativePath() - */ - public const REL_PATH = 2; - - /** - * Generates a network path, e.g. "//example.com/dir/file". - * Such reference reuses the current scheme but specifies the host. - */ - public const NET_PATH = 3; - - /** - * Generates a URL or path for a specific route based on the given parameters. - * - * Parameters that reference placeholders in the route pattern will substitute them in the - * path or host. Extra params are added as query string to the URL. - * - * When the passed reference type cannot be generated for the route because it requires a different - * host or scheme than the current one, the method will return a more comprehensive reference - * that includes the required params. For example, when you call this method with $referenceType = ABSOLUTE_PATH - * but the route requires the https scheme whereas the current scheme is http, it will instead return an - * ABSOLUTE_URL with the https scheme and the current host. This makes sure the generated URL matches - * the route in any case. - * - * If there is no route with the given name, the generator must throw the RouteNotFoundException. - * - * The special parameter _fragment will be used as the document fragment suffixed to the final URL. - * - * @throws RouteNotFoundException If the named route doesn't exist - * @throws MissingMandatoryParametersException When some parameters are missing that are mandatory for the route - * @throws InvalidParameterException When a parameter value for a placeholder is not correct because - * it does not match the requirement - */ - public function generate(string $name, array $parameters = [], int $referenceType = self::ABS_PATH): string; } diff --git a/src/ApiResource/Error.php b/src/ApiResource/Error.php new file mode 100644 index 00000000000..5d6170c7d0f --- /dev/null +++ b/src/ApiResource/Error.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\Metadata\Get; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Annotation\SerializedName; + +#[ErrorResource( + uriTemplate: '/errors/{status}', + provider: 'api_platform.state_provider.default_error', + types: ['hydra:Error'], + operations: [ + new Get(name: '_api_errors_hydra', outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]), + new Get(name: '_api_errors_problem', outputFormats: ['json' => ['application/problem+json']], normalizationContext: ['groups' => ['jsonproblem'], 'skip_null_values' => true]), + new Get(name: '_api_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true], provider: 'api_platform.json_api.state_provider.default_error'), + ] +)] +class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface +{ + public function __construct( + private readonly string $title, + private readonly string $detail, + #[ApiProperty(identifier: true)] private readonly int $status, + #[Groups(['trace'])] + public readonly array $trace, + private ?string $instance = null, + private string $type = 'about:blank', + private array $headers = [] + ) { + parent::__construct(); + } + + #[SerializedName('hydra:title')] + #[Groups(['jsonld', 'legacy_jsonld'])] + public function getHydraTitle(): string + { + return $this->title; + } + + #[SerializedName('hydra:description')] + #[Groups(['jsonld', 'legacy_jsonld'])] + public function getHydraDescription(): string + { + return $this->detail; + } + + #[SerializedName('description')] + #[Groups(['jsonapi', 'legacy_jsonapi'])] + public function getDescription(): string + { + return $this->detail; + } + + public static function createFromException(\Exception|\Throwable $exception, int $status): self + { + $headers = ($exception instanceof SymfonyHttpExceptionInterface || $exception instanceof HttpExceptionInterface) ? $exception->getHeaders() : []; + + return new self('An error occurred', $exception->getMessage(), $status, $exception->getTrace(), type: '/errors/'.$status, headers: $headers); + } + + #[Ignore] + public function getHeaders(): array + { + return $this->headers; + } + + #[Ignore] + public function getStatusCode(): int + { + return $this->status; + } + + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + #[Groups(['jsonld', 'jsonproblem'])] + public function getType(): string + { + return $this->type; + } + + #[Groups(['jsonld', 'legacy_jsonproblem', 'jsonproblem', 'jsonapi', 'legacy_jsonapi'])] + public function getTitle(): ?string + { + return $this->title; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + #[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])] + public function getStatus(): ?int + { + return $this->status; + } + + #[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])] + public function getDetail(): ?string + { + return $this->detail; + } + + #[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])] + public function getInstance(): ?string + { + return $this->instance; + } +} diff --git a/src/Doctrine/Common/.gitignore b/src/Doctrine/Common/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/Doctrine/Common/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/Doctrine/Common/Filter/SearchFilterTrait.php b/src/Doctrine/Common/Filter/SearchFilterTrait.php index e07a201046c..6eb817507e2 100644 --- a/src/Doctrine/Common/Filter/SearchFilterTrait.php +++ b/src/Doctrine/Common/Filter/SearchFilterTrait.php @@ -14,9 +14,9 @@ namespace ApiPlatform\Doctrine\Common\Filter; use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; use Psr\Log\LoggerInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -124,13 +124,7 @@ protected function getIdFromValue(string $value): mixed $iriConverter = $this->getIriConverter(); $item = $iriConverter->getResourceFromIri($value, ['fetch_data' => false]); - if (null === $this->identifiersExtractor) { - return $this->getPropertyAccessor()->getValue($item, 'id'); - } - - $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item); - - return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers; + return $this->getPropertyAccessor()->getValue($item, 'id'); } catch (InvalidArgumentException) { // Do nothing, return the raw value } diff --git a/src/Doctrine/Common/LICENSE b/src/Doctrine/Common/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/Doctrine/Common/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Doctrine/Common/README.md b/src/Doctrine/Common/README.md new file mode 100644 index 00000000000..b4ad5264133 --- /dev/null +++ b/src/Doctrine/Common/README.md @@ -0,0 +1,7 @@ +# API Platform - Doctrine Common + +Common files used by api-platform/doctrine-orm and api-platform/doctrine-odm + +## Resources + + diff --git a/src/Doctrine/Common/State/PersistProcessor.php b/src/Doctrine/Common/State/PersistProcessor.php index 677dfe6148a..9cfe98a5838 100644 --- a/src/Doctrine/Common/State/PersistProcessor.php +++ b/src/Doctrine/Common/State/PersistProcessor.php @@ -17,8 +17,6 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\State\ProcessorInterface; -use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; -use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager as DoctrineObjectManager; @@ -50,7 +48,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = // PUT: reset the existing object managed by Doctrine and merge data sent by the user in it // This custom logic is needed because EntityManager::merge() has been deprecated and UPSERT isn't supported: // https://github.com/doctrine/orm/issues/8461#issuecomment-1250233555 - if ($operation instanceof HttpOperation && HttpOperation::METHOD_PUT === $operation->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? false)) { + if ($operation instanceof HttpOperation && 'PUT' === $operation->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? false)) { \assert(method_exists($manager, 'getReference')); // TODO: the call to getReference is most likely to fail with complex identifiers $newData = $data; @@ -115,7 +113,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = private function isDeferredExplicit(DoctrineObjectManager $manager, $data): bool { $classMetadata = $manager->getClassMetadata($this->getObjectClass($data)); - if (($classMetadata instanceof ClassMetadataInfo || $classMetadata instanceof ClassMetadata) && method_exists($classMetadata, 'isChangeTrackingDeferredExplicit')) { + if (method_exists($classMetadata, 'isChangeTrackingDeferredExplicit')) { return $classMetadata->isChangeTrackingDeferredExplicit(); } diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/Dummy.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/Dummy.php new file mode 100644 index 00000000000..d7c1b17b529 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/Dummy.php @@ -0,0 +1,309 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Dummy. + * + * @author Kévin Dunglas + */ +#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] +#[ORM\Entity] +class Dummy +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + /** + * @var string The dummy name + */ + #[ApiProperty(iris: ['https://schema.org/name'])] + #[ORM\Column] + #[Assert\NotBlank] + private string $name; + + /** + * @var string|null The dummy name alias + */ + #[ApiProperty(iris: ['https://schema.org/alternateName'])] + #[ORM\Column(nullable: true)] + private $alias; + + /** + * @var array foo + */ + private ?array $foo = null; + + /** + * @var string|null A short description of the item + */ + #[ApiProperty(iris: ['https://schema.org/description'])] + #[ORM\Column(nullable: true)] + public $description; + + /** + * @var string|null A dummy + */ + #[ORM\Column(nullable: true)] + public $dummy; + + /** + * @var bool|null A dummy boolean + */ + #[ORM\Column(type: 'boolean', nullable: true)] + + public ?bool $dummyBoolean = null; + /** + * @var \DateTime|null A dummy date + */ + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + #[ORM\Column(type: 'datetime', nullable: true)] + public $dummyDate; + + /** + * @var float|null A dummy float + */ + #[ORM\Column(type: 'float', nullable: true)] + public $dummyFloat; + + /** + * @var string|null A dummy price + */ + #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)] + public $dummyPrice; + + #[ApiProperty(push: true)] + #[ORM\ManyToOne(targetEntity: RelatedDummy::class)] + public ?RelatedDummy $relatedDummy = null; + + #[ORM\ManyToMany(targetEntity: RelatedDummy::class)] + public Collection|iterable $relatedDummies; + + /** + * @var array|null serialize data + */ + #[ORM\Column(type: 'json', nullable: true)] + public $jsonData = []; + + /** + * @var array|null + */ + #[ORM\Column(type: 'simple_array', nullable: true)] + public $arrayData = []; + + /** + * @var string|null + */ + #[ORM\Column(nullable: true)] + public $nameConverted; + + /** + * @var RelatedOwnedDummy|null + */ + #[ORM\OneToOne(targetEntity: RelatedOwnedDummy::class, cascade: ['persist'], mappedBy: 'owningDummy')] + public $relatedOwnedDummy; + + /** + * @var RelatedOwningDummy|null + */ + #[ORM\OneToOne(targetEntity: RelatedOwningDummy::class, cascade: ['persist'], inversedBy: 'ownedDummy')] + public $relatedOwningDummy; + + public static function staticMethod(): void + { + } + + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setAlias($alias): void + { + $this->alias = $alias; + } + + public function getAlias() + { + return $this->alias; + } + + public function setDescription($description): void + { + $this->description = $description; + } + + public function getDescription() + { + return $this->description; + } + + public function fooBar($baz): void + { + } + + public function getFoo(): ?array + { + return $this->foo; + } + + public function setFoo(array $foo = null): void + { + $this->foo = $foo; + } + + public function setDummyDate(\DateTime $dummyDate = null): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function setDummyPrice($dummyPrice) + { + $this->dummyPrice = $dummyPrice; + + return $this; + } + + public function getDummyPrice() + { + return $this->dummyPrice; + } + + public function setJsonData($jsonData): void + { + $this->jsonData = $jsonData; + } + + public function getJsonData() + { + return $this->jsonData; + } + + public function setArrayData($arrayData): void + { + $this->arrayData = $arrayData; + } + + public function getArrayData() + { + return $this->arrayData; + } + + public function getRelatedDummy(): ?RelatedDummy + { + return $this->relatedDummy; + } + + public function setRelatedDummy(RelatedDummy $relatedDummy): void + { + $this->relatedDummy = $relatedDummy; + } + + public function addRelatedDummy(RelatedDummy $relatedDummy): void + { + $this->relatedDummies->add($relatedDummy); + } + + public function getRelatedOwnedDummy() + { + return $this->relatedOwnedDummy; + } + + public function setRelatedOwnedDummy(RelatedOwnedDummy $relatedOwnedDummy): void + { + $this->relatedOwnedDummy = $relatedOwnedDummy; + if ($this !== $this->relatedOwnedDummy->getOwningDummy()) { + $this->relatedOwnedDummy->setOwningDummy($this); + } + } + + public function getRelatedOwningDummy() + { + return $this->relatedOwningDummy; + } + + public function setRelatedOwningDummy(RelatedOwningDummy $relatedOwningDummy): void + { + $this->relatedOwningDummy = $relatedOwningDummy; + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean($dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function setDummy($dummy = null): void + { + $this->dummy = $dummy; + } + + public function getDummy() + { + return $this->dummy; + } + + public function getRelatedDummies(): Collection|iterable + { + return $this->relatedDummies; + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/DummyFriend.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/DummyFriend.php new file mode 100644 index 00000000000..c10beef2a09 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/DummyFriend.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Dummy Friend. + * + * @author Kévin Dunglas + */ +#[ApiResource] +#[ORM\Entity] +class DummyFriend implements \Stringable +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + /** + * @var string The dummy name + */ + #[ApiProperty(types: ['https://schema.org/name'])] + #[ORM\Column] + #[Assert\NotBlank] + #[Groups(['fakemanytomany', 'friends'])] + private string $name; + + /** + * Get id. + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Set id. + * + * @param int $id the value to set + */ + public function setId(int $id): void + { + $this->id = $id; + } + + /** + * Get name. + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Set name. + * + * @param string $name the value to set + */ + public function setName(string $name): void + { + $this->name = $name; + } + + public function __toString(): string + { + return (string) $this->getId(); + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php new file mode 100644 index 00000000000..dd41ef33bf6 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Embeddable Dummy. + * + * @author Jordan Samouh + */ +#[ORM\Embeddable] +class EmbeddableDummy +{ + /** + * @var string The dummy name + */ + #[ORM\Column(nullable: true)] + #[Groups(['embed'])] + private ?string $dummyName = null; + /** + * @var bool|null A dummy boolean + */ + #[ORM\Column(type: 'boolean', nullable: true)] + public ?bool $dummyBoolean = null; + /** + * @var \DateTime|null A dummy date + */ + #[ORM\Column(type: 'datetime', nullable: true)] + #[Assert\DateTime] + public ?\DateTime $dummyDate = null; + /** + * @var float|null A dummy float + */ + #[ORM\Column(type: 'float', nullable: true)] + public ?float $dummyFloat = null; + /** + * @var string|null A dummy price + */ + #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)] + public ?string $dummyPrice = null; + #[ORM\Column(type: 'string', nullable: true)] + #[Groups(['barcelona', 'chicago'])] + protected $symfony; + + public static function staticMethod(): void + { + } + + public function __construct() + { + } + + public function getDummyName(): ?string + { + return $this->dummyName; + } + + public function setDummyName(string $dummyName): void + { + $this->dummyName = $dummyName; + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + public function setDummyBoolean(bool $dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function getDummyDate(): ?\DateTime + { + return $this->dummyDate; + } + + public function setDummyDate(\DateTime $dummyDate): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyFloat(): ?float + { + return $this->dummyFloat; + } + + public function setDummyFloat(float $dummyFloat): void + { + $this->dummyFloat = $dummyFloat; + } + + public function getDummyPrice(): ?string + { + return $this->dummyPrice; + } + + public function setDummyPrice(string $dummyPrice): void + { + $this->dummyPrice = $dummyPrice; + } + + public function getSymfony() + { + return $this->symfony; + } + + public function setSymfony($symfony): void + { + $this->symfony = $symfony; + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/FourthLevel.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/FourthLevel.php new file mode 100644 index 00000000000..0a4b7288979 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/FourthLevel.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Fourth Level. + * + * @author Alan Poulain + */ +#[ApiResource] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}/third_level/fourth_level{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel'), 'thirdLevel' => new Link(fromClass: ThirdLevel::class, identifiers: [], expandedValue: 'third_level', fromProperty: 'fourthLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/id/third_level/fourth_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel'), 'thirdLevel' => new Link(fromClass: ThirdLevel::class, identifiers: [], expandedValue: 'third_level', fromProperty: 'fourthLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/third_level/fourth_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel'), 'thirdLevel' => new Link(fromClass: ThirdLevel::class, identifiers: [], expandedValue: 'third_level', fromProperty: 'fourthLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies/{relatedDummies}/third_level/fourth_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel'), 'thirdLevel' => new Link(fromClass: ThirdLevel::class, identifiers: [], expandedValue: 'third_level', fromProperty: 'fourthLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies/{relatedDummies}/third_level/fourth_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel'), 'thirdLevel' => new Link(fromClass: ThirdLevel::class, identifiers: [], expandedValue: 'third_level', fromProperty: 'fourthLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/third_levels/{id}/fourth_level{._format}', uriVariables: ['id' => new Link(fromClass: ThirdLevel::class, identifiers: ['id'], fromProperty: 'fourthLevel')], status: 200, operations: [new Get()])] +#[ORM\Entity] +class FourthLevel +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue] + private ?int $id = null; + #[ORM\Column(type: 'integer')] + #[Groups(['barcelona', 'chicago'])] + private int $level = 4; + #[ORM\OneToMany(targetEntity: ThirdLevel::class, cascade: ['persist'], mappedBy: 'badFourthLevel')] + public Collection|iterable|null $badThirdLevel = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getLevel(): ?int + { + return $this->level; + } + + public function setLevel(int $level): void + { + $this->level = $level; + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/ParentDummy.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/ParentDummy.php new file mode 100644 index 00000000000..105b60e5291 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/ParentDummy.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\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Parent Dummy. + * + * @author Kévin Dunglas + */ +#[ORM\MappedSuperclass] +class ParentDummy +{ + /** + * @var int|null The age + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[Groups(['friends'])] + private $age; + + public function getAge() + { + return $this->age; + } + + public function setAge($age) + { + return $this->age = $age; + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedDummy.php new file mode 100644 index 00000000000..718c00e5a06 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Related Dummy. + * + * @author Kévin Dunglas + */ +#[ApiResource( + graphQlOperations: [ + new Query(name: 'item_query'), + new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), + ], + types: ['https://schema.org/Product'], + normalizationContext: ['groups' => ['friends']], + filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'] +)] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/id{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] +#[ApiFilter(filterClass: SearchFilter::class, properties: ['id'])] +#[ORM\Entity] +class RelatedDummy extends ParentDummy implements \Stringable +{ + #[ApiProperty(writable: false)] + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Groups(['chicago', 'friends'])] + private $id; + + /** + * @var string|null A name + */ + #[ApiProperty(iris: ['RelatedDummy.name'])] + #[ORM\Column(nullable: true)] + #[Groups(['friends'])] + public $name; + + #[ApiProperty(deprecationReason: 'This property is deprecated for upgrade test')] + #[ORM\Column] + #[Groups(['barcelona', 'chicago', 'friends'])] + #[ApiFilter(filterClass: SearchFilter::class)] + #[ApiFilter(filterClass: ExistsFilter::class)] + protected $symfony = 'symfony'; + + /** + * @var \DateTime|null A dummy date + */ + #[ORM\Column(type: 'datetime', nullable: true)] + #[Assert\DateTime] + #[Groups(['friends'])] + #[ApiFilter(filterClass: DateFilter::class)] + public $dummyDate; + + #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'])] + #[Groups(['barcelona', 'chicago', 'friends'])] + public ?ThirdLevel $thirdLevel = null; + + #[ORM\OneToMany(targetEntity: RelatedToDummyFriend::class, cascade: ['persist'], mappedBy: 'relatedDummy')] + #[Groups(['fakemanytomany', 'friends'])] + public Collection|iterable $relatedToDummyFriend; + + /** + * @var bool|null A dummy bool + */ + #[ORM\Column(type: 'boolean', nullable: true)] + #[Groups(['friends'])] + public ?bool $dummyBoolean = null; + + #[ORM\Embedded(class: 'EmbeddableDummy')] + #[Groups(['friends'])] + public ?EmbeddableDummy $embeddedDummy = null; + + public function __construct() + { + $this->relatedToDummyFriend = new ArrayCollection(); + $this->embeddedDummy = new EmbeddableDummy(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName($name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getSymfony() + { + return $this->symfony; + } + + public function setSymfony($symfony): void + { + $this->symfony = $symfony; + } + + public function setDummyDate(\DateTime $dummyDate): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean($dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function getThirdLevel(): ?ThirdLevel + { + return $this->thirdLevel; + } + + public function setThirdLevel(ThirdLevel $thirdLevel = null): void + { + $this->thirdLevel = $thirdLevel; + } + + /** + * Get relatedToDummyFriend. + */ + public function getRelatedToDummyFriend(): Collection|iterable + { + return $this->relatedToDummyFriend; + } + + /** + * Set relatedToDummyFriend. + * + * @param RelatedToDummyFriend $relatedToDummyFriend the value to set + */ + public function addRelatedToDummyFriend(RelatedToDummyFriend $relatedToDummyFriend): void + { + $this->relatedToDummyFriend->add($relatedToDummyFriend); + } + + public function getEmbeddedDummy(): EmbeddableDummy + { + return $this->embeddedDummy; + } + + public function setEmbeddedDummy(EmbeddableDummy $embeddedDummy): void + { + $this->embeddedDummy = $embeddedDummy; + } + + public function __toString(): string + { + return (string) $this->getId(); + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedOwnedDummy.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedOwnedDummy.php new file mode 100644 index 00000000000..7f27c9c3c5d --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedOwnedDummy.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Related Owned Dummy. + * + * @author Sergey V. Ryabov + */ +#[ApiResource(types: ['https://schema.org/Product'])] +#[ORM\Entity] +class RelatedOwnedDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + /** + * @var string|null A name + */ + #[ORM\Column(nullable: true)] + public ?string $name = null; + #[ORM\OneToOne(targetEntity: Dummy::class, cascade: ['persist'], inversedBy: 'relatedOwnedDummy')] + #[ORM\JoinColumn(nullable: false)] + public ?Dummy $owningDummy = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(int $id): void + { + $this->id = $id; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getName(): ?string + { + return $this->name; + } + + /** + * Get owning dummy. + */ + public function getOwningDummy(): ?Dummy + { + return $this->owningDummy; + } + + /** + * Set owning dummy. + * + * @param Dummy $owningDummy the value to set + */ + public function setOwningDummy(Dummy $owningDummy): void + { + $this->owningDummy = $owningDummy; + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedOwningDummy.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedOwningDummy.php new file mode 100644 index 00000000000..768dc37fcfd --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedOwningDummy.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Related Owning Dummy. + * + * @author Sergey V. Ryabov + */ +#[ApiResource(types: ['https://schema.org/Product'])] +#[ORM\Entity] +class RelatedOwningDummy +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + /** + * @var string|null A name + */ + #[ORM\Column(nullable: true)] + public $name; + #[ORM\OneToOne(targetEntity: Dummy::class, cascade: ['persist'], mappedBy: 'relatedOwningDummy')] + public ?Dummy $ownedDummy = null; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName($name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + /** + * Get owned dummy. + */ + public function getOwnedDummy(): Dummy + { + return $this->ownedDummy; + } + + /** + * Set owned dummy. + * + * @param Dummy $ownedDummy the value to set + */ + public function setOwnedDummy(Dummy $ownedDummy): void + { + $this->ownedDummy = $ownedDummy; + if ($this !== $this->ownedDummy->getRelatedOwningDummy()) { + $this->ownedDummy->setRelatedOwningDummy($this); + } + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php new file mode 100644 index 00000000000..45fca2fa8b5 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Related To Dummy Friend represent an association table for a manytomany relation. + */ +#[ApiResource(normalizationContext: ['groups' => ['fakemanytomany']], filters: ['related_to_dummy_friend.name'])] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/id/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies/{relatedDummies}/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies/{relatedDummies}/related_to_dummy_friends{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], toProperty: 'relatedDummy')], status: 200, filters: ['related_to_dummy_friend.name'], normalizationContext: ['groups' => ['fakemanytomany']], operations: [new GetCollection()])] +#[ORM\Entity] +class RelatedToDummyFriend +{ + /** + * @var string The dummy name + */ + #[ApiProperty(types: ['https://schema.org/name'])] + #[ORM\Column] + #[Assert\NotBlank] + #[Groups(['fakemanytomany', 'friends'])] + private $name; + /** + * @var string|null The dummy description + */ + #[ORM\Column(nullable: true)] + #[Groups(['fakemanytomany', 'friends'])] + private ?string $description = null; + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: DummyFriend::class)] + #[ORM\JoinColumn(name: 'dummyfriend_id', referencedColumnName: 'id', nullable: false)] + #[Groups(['fakemanytomany', 'friends'])] + #[Assert\NotNull] + private DummyFriend $dummyFriend; + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: RelatedDummy::class, inversedBy: 'relatedToDummyFriend')] + #[ORM\JoinColumn(name: 'relateddummy_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[Assert\NotNull] + private RelatedDummy $relatedDummy; + + public function setName($name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + */ + public function setDescription($description): void + { + $this->description = $description; + } + + /** + * Gets dummyFriend. + */ + public function getDummyFriend(): DummyFriend + { + return $this->dummyFriend; + } + + /** + * Sets dummyFriend. + * + * @param DummyFriend $dummyFriend the value to set + */ + public function setDummyFriend(DummyFriend $dummyFriend): void + { + $this->dummyFriend = $dummyFriend; + } + + /** + * Gets relatedDummy. + */ + public function getRelatedDummy(): RelatedDummy + { + return $this->relatedDummy; + } + + /** + * Sets relatedDummy. + * + * @param RelatedDummy $relatedDummy the value to set + */ + public function setRelatedDummy(RelatedDummy $relatedDummy): void + { + $this->relatedDummy = $relatedDummy; + } +} diff --git a/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/ThirdLevel.php b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/ThirdLevel.php new file mode 100644 index 00000000000..948101bac72 --- /dev/null +++ b/src/Doctrine/Common/Tests/Fixtures/TestBundle/Entity/ThirdLevel.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\Doctrine\Common\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Third Level. + * + * @author Kévin Dunglas + */ +#[ApiResource] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}/third_level{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/id/third_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/third_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies/{relatedDummies}/third_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel')], status: 200, operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies/{relatedDummies}/third_level{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: RelatedDummy::class, identifiers: ['id'], fromProperty: 'thirdLevel')], status: 200, operations: [new Get()])] +#[ORM\Entity] +class ThirdLevel +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + #[ORM\Column(type: 'integer')] + #[Groups(['barcelona', 'chicago'])] + private int $level = 3; + #[ORM\Column(type: 'boolean')] + private bool $test = true; + #[ORM\ManyToOne(targetEntity: FourthLevel::class, cascade: ['persist'])] + #[Groups(['barcelona', 'chicago', 'friends'])] + public ?FourthLevel $fourthLevel = null; + #[ORM\ManyToOne(targetEntity: FourthLevel::class, cascade: ['persist'])] + public $badFourthLevel; + + public function getId(): ?int + { + return $this->id; + } + + public function getLevel(): ?int + { + return $this->level; + } + + /** + * @param int $level + */ + public function setLevel($level): void + { + $this->level = $level; + } + + public function isTest(): bool + { + return $this->test; + } + + /** + * @param bool $test + */ + public function setTest($test): void + { + $this->test = $test; + } + + public function getFourthLevel(): ?FourthLevel + { + return $this->fourthLevel; + } + + public function setFourthLevel(FourthLevel $fourthLevel = null): void + { + $this->fourthLevel = $fourthLevel; + } +} diff --git a/tests/Doctrine/Common/State/PersistProcessorTest.php b/src/Doctrine/Common/Tests/State/PersistProcessorTest.php similarity index 97% rename from tests/Doctrine/Common/State/PersistProcessorTest.php rename to src/Doctrine/Common/Tests/State/PersistProcessorTest.php index 9b3a7e3e383..8b83dd9dd4c 100644 --- a/tests/Doctrine/Common/State/PersistProcessorTest.php +++ b/src/Doctrine/Common/Tests/State/PersistProcessorTest.php @@ -11,12 +11,12 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Doctrine\Common\State; +namespace ApiPlatform\Doctrine\Common\Tests\State; use ApiPlatform\Doctrine\Common\State\PersistProcessor; +use ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Metadata\Get; use ApiPlatform\State\ProcessorInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\Persistence\ManagerRegistry; diff --git a/tests/Doctrine/Common/State/RemoveProcessorTest.php b/src/Doctrine/Common/Tests/State/RemoveProcessorTest.php similarity index 93% rename from tests/Doctrine/Common/State/RemoveProcessorTest.php rename to src/Doctrine/Common/Tests/State/RemoveProcessorTest.php index fc55f1bdcfb..a0ecc1bb9e2 100644 --- a/tests/Doctrine/Common/State/RemoveProcessorTest.php +++ b/src/Doctrine/Common/Tests/State/RemoveProcessorTest.php @@ -11,12 +11,12 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Doctrine\Common\State; +namespace ApiPlatform\Doctrine\Common\Tests\State; use ApiPlatform\Doctrine\Common\State\RemoveProcessor; +use ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Metadata\Delete; use ApiPlatform\State\ProcessorInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use PHPUnit\Framework\TestCase; diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json new file mode 100644 index 00000000000..52272764052 --- /dev/null +++ b/src/Doctrine/Common/composer.json @@ -0,0 +1,78 @@ +{ + "name": "api-platform/doctrine-common", + "description": "Common files used by api-platform/doctrine-orm and api-platform/doctrine-odm", + "type": "library", + "keywords": [ + "DOCTRINE", + "ORM", + "ODM", + "COMMON" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/state": "*@dev || ^3.1", + "api-platform/metadata": "*@dev || ^3.1" + }, + "require-dev": { + "symfony/phpunit-bridge": "^6.1", + "phpstan/phpdoc-parser": "^1.16", + "symfony/routing": "^6.1", + "symfony/yaml": "^6.1", + "symfony/config": "^6.1", + "doctrine/orm": "^2.14", + "doctrine/mongodb-odm": "^2.2", + "doctrine/mongodb-odm-bundle": "^4.0", + "doctrine/common": "^3.2.2", + "phpspec/prophecy-phpunit": "^2.0" + }, + "conflict": { + "doctrine/persistence": "<1.3" + }, + "suggest": { + "phpstan/phpdoc-parser": "For PHP documentation support.", + "symfony/yaml": "For YAML resource configuration.", + "symfony/config": "For XML resource configuration." + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Doctrine\\Common\\": "" + } + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../../Metadata" + }, + { + "type": "path", + "url": "../../State" + } + ] +} diff --git a/src/Doctrine/Common/phpunit.xml.dist b/src/Doctrine/Common/phpunit.xml.dist new file mode 100644 index 00000000000..326841db8f3 --- /dev/null +++ b/src/Doctrine/Common/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + + diff --git a/src/Doctrine/Odm/Filter/FilterInterface.php b/src/Doctrine/Odm/Filter/FilterInterface.php index b609e7f9fc7..8dc3c0631c4 100644 --- a/src/Doctrine/Odm/Filter/FilterInterface.php +++ b/src/Doctrine/Odm/Filter/FilterInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; -use ApiPlatform\Api\FilterInterface as BaseFilterInterface; +use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index 4ef168a95ad..df89d572170 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -148,7 +148,7 @@ protected function addMatch(Builder $aggregationBuilder, string $field, string $ { switch ($operator) { case self::PARAMETER_BETWEEN: - $rangeValue = explode('..', $value); + $rangeValue = explode('..', $value, 2); $rangeValue = $this->normalizeBetweenValues($rangeValue); if (null === $rangeValue) { diff --git a/src/Doctrine/Odm/Filter/SearchFilter.php b/src/Doctrine/Odm/Filter/SearchFilter.php index ea6c725c830..703d66d3ff7 100644 --- a/src/Doctrine/Odm/Filter/SearchFilter.php +++ b/src/Doctrine/Odm/Filter/SearchFilter.php @@ -14,10 +14,10 @@ namespace ApiPlatform\Doctrine\Odm\Filter; use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDBClassMetadata; diff --git a/src/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php b/src/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php index 3f2548712fc..1afd8b2b9ed 100644 --- a/src/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php +++ b/src/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php @@ -13,11 +13,11 @@ namespace ApiPlatform\Doctrine\Orm\Extension; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\Query\Expr\Join; diff --git a/src/Doctrine/Orm/Filter/FilterInterface.php b/src/Doctrine/Orm/Filter/FilterInterface.php index 5fd7ff27334..50e02b12f71 100644 --- a/src/Doctrine/Orm/Filter/FilterInterface.php +++ b/src/Doctrine/Orm/Filter/FilterInterface.php @@ -13,8 +13,8 @@ namespace ApiPlatform\Doctrine\Orm\Filter; -use ApiPlatform\Api\FilterInterface as BaseFilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index 4d44ad4dde5..14233509b52 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -153,7 +153,7 @@ protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterf switch ($operator) { case self::PARAMETER_BETWEEN: - $rangeValue = explode('..', $value); + $rangeValue = explode('..', $value, 2); $rangeValue = $this->normalizeBetweenValues($rangeValue); if (null === $rangeValue) { diff --git a/src/Doctrine/Orm/Filter/SearchFilter.php b/src/Doctrine/Orm/Filter/SearchFilter.php index 961345e8d73..1d33941149a 100644 --- a/src/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Doctrine/Orm/Filter/SearchFilter.php @@ -14,12 +14,12 @@ namespace ApiPlatform\Doctrine\Orm\Filter; use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\Expr\Join; @@ -196,7 +196,6 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if ($metadata->hasField($field)) { if ('id' === $field) { $values = array_map($this->getIdFromValue(...), $values); - // todo: handle composite IDs } if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { @@ -218,11 +217,9 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB } $values = array_map($this->getIdFromValue(...), $values); - // todo: handle composite IDs $associationResourceClass = $metadata->getAssociationTargetClass($field); - $associationMetadata = $this->getClassMetadata($associationResourceClass); - $associationFieldIdentifier = $associationMetadata->getIdentifierFieldNames()[0]; + $associationFieldIdentifier = $metadata->getIdentifierFieldNames()[0]; $doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass); if (!$this->hasValidValues($values, $doctrineTypeField)) { diff --git a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactory.php b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactory.php index 43ac0e25436..1398e1f0955 100644 --- a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactory.php +++ b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactory.php @@ -14,9 +14,8 @@ namespace ApiPlatform\Doctrine\Orm\Metadata\Resource; use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Metadata; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\PropertyLinkFactoryInterface; @@ -35,7 +34,7 @@ public function __construct(private readonly ManagerRegistry $managerRegistry, p /** * {@inheritdoc} */ - public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link + public function createLinkFromProperty(Metadata $operation, string $property): Link { return $this->linkFactory->createLinkFromProperty($operation, $property); } @@ -43,7 +42,7 @@ public function createLinkFromProperty(ApiResource|Operation $operation, string /** * {@inheritdoc} */ - public function createLinksFromIdentifiers(ApiResource|Operation $operation): array + public function createLinksFromIdentifiers(Metadata $operation): array { return $this->linkFactory->createLinksFromIdentifiers($operation); } @@ -51,7 +50,7 @@ public function createLinksFromIdentifiers(ApiResource|Operation $operation): ar /** * {@inheritdoc} */ - public function createLinksFromRelations(ApiResource|Operation $operation): array + public function createLinksFromRelations(Metadata $operation): array { $links = $this->linkFactory->createLinksFromRelations($operation); @@ -82,7 +81,7 @@ public function createLinksFromRelations(ApiResource|Operation $operation): arra /** * {@inheritdoc} */ - public function createLinksFromAttributes(ApiResource|Operation $operation): array + public function createLinksFromAttributes(Metadata $operation): array { return $this->linkFactory->createLinksFromAttributes($operation); } diff --git a/src/Elasticsearch/.gitignore b/src/Elasticsearch/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/Elasticsearch/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/Elasticsearch/Exception/ExceptionInterface.php b/src/Elasticsearch/Exception/ExceptionInterface.php new file mode 100644 index 00000000000..5e2f5471b79 --- /dev/null +++ b/src/Elasticsearch/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Exception; + +/** + * Base exception interface. + * + * @author Kévin Dunglas + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Elasticsearch/Exception/IndexNotFoundException.php b/src/Elasticsearch/Exception/IndexNotFoundException.php index c12ca5d6b9e..a92528a0deb 100644 --- a/src/Elasticsearch/Exception/IndexNotFoundException.php +++ b/src/Elasticsearch/Exception/IndexNotFoundException.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Elasticsearch\Exception; -use ApiPlatform\Exception\ExceptionInterface; - /** * Index not found exception. * diff --git a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php index e2d007b1f70..624ff936c01 100644 --- a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php +++ b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Elasticsearch\Exception; -use ApiPlatform\Exception\ExceptionInterface; - /** * Non unique identifier exception. * diff --git a/src/Elasticsearch/Extension/SortExtension.php b/src/Elasticsearch/Extension/SortExtension.php index 207276edaa7..02c6358358b 100644 --- a/src/Elasticsearch/Extension/SortExtension.php +++ b/src/Elasticsearch/Extension/SortExtension.php @@ -13,11 +13,11 @@ namespace ApiPlatform\Elasticsearch\Extension; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Elasticsearch\Util\FieldDatatypeTrait; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index 90b37273c69..a989cd18339 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -13,12 +13,12 @@ namespace ApiPlatform\Elasticsearch\Filter; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Elasticsearch\Util\FieldDatatypeTrait; -use ApiPlatform\Exception\PropertyNotFoundException; -use ApiPlatform\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -93,46 +93,70 @@ protected function getMetadata(string $resourceClass, string $property): array return $noop; } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes(); - if (null === $type) { + if (null === $types) { return $noop; } ++$index; - $builtinType = $type->getBuiltinType(); - if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) { - if ($totalProperties === $index) { - break; + // check each type before deciding if it's noop or not + // e.g: maybe the first type is noop, but the second is valid + $isNoop = false; + + foreach ($types as $type) { + $builtinType = $type->getBuiltinType(); + + if (Type::BUILTIN_TYPE_OBJECT !== $builtinType && Type::BUILTIN_TYPE_ARRAY !== $builtinType) { + if ($totalProperties === $index) { + break 2; + } + + $isNoop = true; + + continue; } - return $noop; - } + if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) { + $isNoop = true; - if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) { - return $noop; - } + continue; + } + + if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) { + if ($totalProperties === $index) { + break 2; + } + + $isNoop = true; - if (Type::BUILTIN_TYPE_ARRAY === $builtinType && Type::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) { - if ($totalProperties === $index) { - break; + continue; } - return $noop; - } + if (null === $className = $type->getClassName()) { + $isNoop = true; - if (null === $className = $type->getClassName()) { - return $noop; + continue; + } + + if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) { + $currentResourceClass = $className; + } elseif ($totalProperties !== $index) { + $isNoop = true; + + continue; + } + + $hasAssociation = $totalProperties === $index && $isResourceClass; + $isNoop = false; + + break; } - if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) { - $currentResourceClass = $className; - } elseif ($totalProperties !== $index) { + if ($isNoop) { return $noop; } - - $hasAssociation = $totalProperties === $index && $isResourceClass; } return [$type, $hasAssociation, $currentResourceClass, $currentProperty]; diff --git a/src/Elasticsearch/Filter/AbstractSearchFilter.php b/src/Elasticsearch/Filter/AbstractSearchFilter.php index 59f27519c4b..ed94729fc0f 100644 --- a/src/Elasticsearch/Filter/AbstractSearchFilter.php +++ b/src/Elasticsearch/Filter/AbstractSearchFilter.php @@ -13,13 +13,13 @@ namespace ApiPlatform\Elasticsearch\Filter; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; diff --git a/src/Elasticsearch/Filter/FilterInterface.php b/src/Elasticsearch/Filter/FilterInterface.php index 44b1ef1d7bd..2a248a19448 100644 --- a/src/Elasticsearch/Filter/FilterInterface.php +++ b/src/Elasticsearch/Filter/FilterInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Elasticsearch\Filter; -use ApiPlatform\Api\FilterInterface as BaseFilterInterface; +use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; /** diff --git a/src/Elasticsearch/Filter/OrderFilter.php b/src/Elasticsearch/Filter/OrderFilter.php index b00f36a3f0d..cd2504a5e8e 100644 --- a/src/Elasticsearch/Filter/OrderFilter.php +++ b/src/Elasticsearch/Filter/OrderFilter.php @@ -13,10 +13,10 @@ namespace ApiPlatform\Elasticsearch\Filter; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** diff --git a/src/Elasticsearch/LICENSE b/src/Elasticsearch/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/Elasticsearch/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php b/src/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php index 9b8a25fe0a6..12b5dca1d46 100644 --- a/src/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php +++ b/src/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php @@ -16,7 +16,7 @@ use ApiPlatform\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Util\Inflector; +use ApiPlatform\Metadata\Util\Inflector; use Elasticsearch\Client; use Elasticsearch\Common\Exceptions\Missing404Exception; diff --git a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php index 6fbb5de58f3..dbe7e2b634c 100644 --- a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php +++ b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php @@ -20,7 +20,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Util\Inflector; +use ApiPlatform\Metadata\Util\Inflector; use Elasticsearch\Client; use Elasticsearch\Common\Exceptions\Missing404Exception; use Elasticsearch\Common\Exceptions\NoNodesAvailableException; diff --git a/src/Elasticsearch/README.md b/src/Elasticsearch/README.md new file mode 100644 index 00000000000..354344479f6 --- /dev/null +++ b/src/Elasticsearch/README.md @@ -0,0 +1,7 @@ +# API Platform - Elasticsearch + +Elasticsearch Support. + +## Resources + + diff --git a/src/Elasticsearch/State/CollectionProvider.php b/src/Elasticsearch/State/CollectionProvider.php index 483978cf7e9..f2b612b8747 100644 --- a/src/Elasticsearch/State/CollectionProvider.php +++ b/src/Elasticsearch/State/CollectionProvider.php @@ -19,9 +19,9 @@ use ApiPlatform\Elasticsearch\Paginator; use ApiPlatform\Elasticsearch\Util\ElasticsearchVersion; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Util\Inflector; use Elasticsearch\Client; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; diff --git a/src/Elasticsearch/State/ItemProvider.php b/src/Elasticsearch/State/ItemProvider.php index 69be23c1e40..bf0723281fd 100644 --- a/src/Elasticsearch/State/ItemProvider.php +++ b/src/Elasticsearch/State/ItemProvider.php @@ -18,8 +18,8 @@ use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Elasticsearch\Util\ElasticsearchVersion; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Util\Inflector; use Elasticsearch\Client; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; diff --git a/tests/Elasticsearch/Exception/IndexNotFoundExceptionTest.php b/src/Elasticsearch/Tests/Exception/IndexNotFoundExceptionTest.php similarity index 86% rename from tests/Elasticsearch/Exception/IndexNotFoundExceptionTest.php rename to src/Elasticsearch/Tests/Exception/IndexNotFoundExceptionTest.php index facf3ed6fe4..05dbb8d38e5 100644 --- a/tests/Elasticsearch/Exception/IndexNotFoundExceptionTest.php +++ b/src/Elasticsearch/Tests/Exception/IndexNotFoundExceptionTest.php @@ -11,10 +11,10 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Exception; +namespace ApiPlatform\Elasticsearch\Tests\Exception; +use ApiPlatform\Elasticsearch\Exception\ExceptionInterface; use ApiPlatform\Elasticsearch\Exception\IndexNotFoundException; -use ApiPlatform\Exception\ExceptionInterface; use PHPUnit\Framework\TestCase; class IndexNotFoundExceptionTest extends TestCase diff --git a/tests/Elasticsearch/Exception/NonUniqueIdentifierExceptionTest.php b/src/Elasticsearch/Tests/Exception/NonUniqueIdentifierExceptionTest.php similarity index 93% rename from tests/Elasticsearch/Exception/NonUniqueIdentifierExceptionTest.php rename to src/Elasticsearch/Tests/Exception/NonUniqueIdentifierExceptionTest.php index 4e56ec5defd..997d26d92cc 100644 --- a/tests/Elasticsearch/Exception/NonUniqueIdentifierExceptionTest.php +++ b/src/Elasticsearch/Tests/Exception/NonUniqueIdentifierExceptionTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Exception; +namespace ApiPlatform\Elasticsearch\Tests\Exception; use ApiPlatform\Elasticsearch\Exception\NonUniqueIdentifierException; use PHPUnit\Framework\TestCase; diff --git a/tests/Elasticsearch/Extension/ConstantScoreFilterExtensionTest.php b/src/Elasticsearch/Tests/Extension/ConstantScoreFilterExtensionTest.php similarity index 96% rename from tests/Elasticsearch/Extension/ConstantScoreFilterExtensionTest.php rename to src/Elasticsearch/Tests/Extension/ConstantScoreFilterExtensionTest.php index 85bdff6a222..b651adac671 100644 --- a/tests/Elasticsearch/Extension/ConstantScoreFilterExtensionTest.php +++ b/src/Elasticsearch/Tests/Extension/ConstantScoreFilterExtensionTest.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Extension; +namespace ApiPlatform\Elasticsearch\Tests\Extension; use ApiPlatform\Elasticsearch\Extension\ConstantScoreFilterExtension; use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Filter\ConstantScoreFilterInterface; use ApiPlatform\Elasticsearch\Filter\SortFilterInterface; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\Get; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; diff --git a/tests/Elasticsearch/Extension/SortExtensionTest.php b/src/Elasticsearch/Tests/Extension/SortExtensionTest.php similarity index 96% rename from tests/Elasticsearch/Extension/SortExtensionTest.php rename to src/Elasticsearch/Tests/Extension/SortExtensionTest.php index a2da55c057b..f317a46e1a4 100644 --- a/tests/Elasticsearch/Extension/SortExtensionTest.php +++ b/src/Elasticsearch/Tests/Extension/SortExtensionTest.php @@ -11,15 +11,15 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Extension; +namespace ApiPlatform\Elasticsearch\Tests\Extension; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Extension\SortExtension; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyInfo\Type; diff --git a/tests/Elasticsearch/Extension/SortFilterExtensionTest.php b/src/Elasticsearch/Tests/Extension/SortFilterExtensionTest.php similarity index 96% rename from tests/Elasticsearch/Extension/SortFilterExtensionTest.php rename to src/Elasticsearch/Tests/Extension/SortFilterExtensionTest.php index b867f9a5064..a1dd3b4151c 100644 --- a/tests/Elasticsearch/Extension/SortFilterExtensionTest.php +++ b/src/Elasticsearch/Tests/Extension/SortFilterExtensionTest.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Extension; +namespace ApiPlatform\Elasticsearch\Tests\Extension; use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Extension\SortFilterExtension; use ApiPlatform\Elasticsearch\Filter\ConstantScoreFilterInterface; use ApiPlatform\Elasticsearch\Filter\SortFilterInterface; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; diff --git a/tests/Elasticsearch/Filter/MatchFilterTest.php b/src/Elasticsearch/Tests/Filter/MatchFilterTest.php similarity index 97% rename from tests/Elasticsearch/Filter/MatchFilterTest.php rename to src/Elasticsearch/Tests/Filter/MatchFilterTest.php index d042392ee84..b03b40b6764 100644 --- a/tests/Elasticsearch/Filter/MatchFilterTest.php +++ b/src/Elasticsearch/Tests/Filter/MatchFilterTest.php @@ -11,18 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Filter; +namespace ApiPlatform\Elasticsearch\Tests\Filter; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Elasticsearch\Filter\ConstantScoreFilterInterface; use ApiPlatform\Elasticsearch\Filter\MatchFilter; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Elasticsearch/Filter/OrderFilterTest.php b/src/Elasticsearch/Tests/Filter/OrderFilterTest.php similarity index 97% rename from tests/Elasticsearch/Filter/OrderFilterTest.php rename to src/Elasticsearch/Tests/Filter/OrderFilterTest.php index 9507f938329..87f3186afb9 100644 --- a/tests/Elasticsearch/Filter/OrderFilterTest.php +++ b/src/Elasticsearch/Tests/Filter/OrderFilterTest.php @@ -11,16 +11,16 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Filter; +namespace ApiPlatform\Elasticsearch\Tests\Filter; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Elasticsearch\Filter\OrderFilter; use ApiPlatform\Elasticsearch\Filter\SortFilterInterface; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Elasticsearch/Filter/TermFilterTest.php b/src/Elasticsearch/Tests/Filter/TermFilterTest.php similarity index 97% rename from tests/Elasticsearch/Filter/TermFilterTest.php rename to src/Elasticsearch/Tests/Filter/TermFilterTest.php index 1f7c8ca5731..a73abe96b4a 100644 --- a/tests/Elasticsearch/Filter/TermFilterTest.php +++ b/src/Elasticsearch/Tests/Filter/TermFilterTest.php @@ -11,18 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Filter; +namespace ApiPlatform\Elasticsearch\Tests\Filter; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Elasticsearch\Filter\ConstantScoreFilterInterface; use ApiPlatform\Elasticsearch\Filter\TermFilter; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/src/Elasticsearch/Tests/Fixtures/Book.php b/src/Elasticsearch/Tests/Fixtures/Book.php new file mode 100644 index 00000000000..9a5352d0769 --- /dev/null +++ b/src/Elasticsearch/Tests/Fixtures/Book.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\Elasticsearch\Tests\Fixtures; + +use ApiPlatform\Elasticsearch\Filter\MatchFilter; +use ApiPlatform\Elasticsearch\Filter\OrderFilter; +use ApiPlatform\Elasticsearch\State\CollectionProvider; +use ApiPlatform\Elasticsearch\State\ItemProvider; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(operations: [new Get(provider: ItemProvider::class), new GetCollection(provider: CollectionProvider::class)], normalizationContext: ['groups' => ['book:read']])] +#[ApiFilter(OrderFilter::class, properties: ['id', 'library.id'])] +#[ApiFilter(MatchFilter::class, properties: ['message', 'library.firstName'])] +class Book +{ + #[Groups(['book:read', 'library:read'])] + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Groups(['book:read'])] + public ?Library $library = null; + + #[Groups(['book:read', 'library:read'])] + public ?\DateTimeImmutable $date = null; + + #[Groups(['book:read', 'library:read'])] + public ?string $message = null; +} diff --git a/src/Elasticsearch/Tests/Fixtures/Foo.php b/src/Elasticsearch/Tests/Fixtures/Foo.php new file mode 100644 index 00000000000..9fae710e1aa --- /dev/null +++ b/src/Elasticsearch/Tests/Fixtures/Foo.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\Elasticsearch\Tests\Fixtures; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Put; + +/** + * Foo. + * + * @author Vincent Chalamon + */ +#[ApiResource(operations: [new Get(), new Put(), new Patch(), new Delete(), new GetCollection(), new GetCollection(uriTemplate: 'custom_collection_desc_foos', order: ['name' => 'DESC']), new GetCollection(uriTemplate: 'custom_collection_asc_foos', order: ['name' => 'ASC'])], graphQlOperations: [new Query(name: 'item_query'), new QueryCollection(name: 'collection_query', paginationEnabled: false), new Mutation(name: 'create'), new Mutation(name: 'delete')], order: ['bar', 'name' => 'DESC'])] +class Foo +{ + /** + * @var int The id + */ + #[ApiProperty(identifier: true)] + private ?int $id = null; + + /** + * @var string The foo name + */ + private $name; + + /** + * @var string The foo bar + */ + private $bar; + + public function getId(): ?int + { + return $this->id; + } + + public function setName($name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getBar() + { + return $this->bar; + } + + public function setBar($bar): void + { + $this->bar = $bar; + } +} diff --git a/src/Elasticsearch/Tests/Fixtures/Library.php b/src/Elasticsearch/Tests/Fixtures/Library.php new file mode 100644 index 00000000000..efdd0f278f6 --- /dev/null +++ b/src/Elasticsearch/Tests/Fixtures/Library.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Fixtures; + +use ApiPlatform\Elasticsearch\Filter\TermFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(normalizationContext: ['groups' => ['library:read']])] +#[ApiFilter(TermFilter::class, properties: ['id', 'gender', 'age', 'firstName', 'books.id', 'books.date'])] +class Library +{ + #[ApiProperty(identifier: true)] + #[Groups(['book:read', 'library:read'])] + public ?string $id = null; + + #[Groups(['book:read', 'library:read'])] + public ?string $gender = null; + + #[Groups(['book:read', 'library:read'])] + public ?int $age = null; + + #[Groups(['book:read', 'library:read'])] + public ?string $firstName = null; + + #[Groups(['book:read', 'library:read'])] + public ?string $lastName = null; + + #[Groups(['library:read'])] + public ?\DateTimeInterface $registeredAt = null; + + /** @var Book[] */ + #[Groups(['library:read'])] + public array $books = []; +} diff --git a/src/Elasticsearch/Tests/Fixtures/Tweet.php b/src/Elasticsearch/Tests/Fixtures/Tweet.php new file mode 100644 index 00000000000..1627a806e85 --- /dev/null +++ b/src/Elasticsearch/Tests/Fixtures/Tweet.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Fixtures; + +use ApiPlatform\Elasticsearch\Filter\MatchFilter; +use ApiPlatform\Elasticsearch\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(normalizationContext: ['groups' => ['tweet:read']])] +#[ApiFilter(OrderFilter::class, properties: ['id', 'author.id'])] +#[ApiFilter(MatchFilter::class, properties: ['message', 'author.firstName'])] +class Tweet +{ + #[Groups(['tweet:read', 'user:read'])] + #[ApiProperty(identifier: true)] + private ?string $id = null; + + #[Groups(['tweet:read'])] + private ?User $author = null; + + #[Groups(['tweet:read', 'user:read'])] + private ?\DateTimeImmutable $date = null; + + #[Groups(['tweet:read', 'user:read'])] + private ?string $message = null; + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(User $author): void + { + $this->author = $author; + } + + public function getDate(): ?\DateTimeImmutable + { + return $this->date; + } + + public function setDate(\DateTimeImmutable $date): void + { + $this->date = $date; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): void + { + $this->message = $message; + } +} diff --git a/src/Elasticsearch/Tests/Fixtures/User.php b/src/Elasticsearch/Tests/Fixtures/User.php new file mode 100644 index 00000000000..9028bf71475 --- /dev/null +++ b/src/Elasticsearch/Tests/Fixtures/User.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Fixtures; + +use ApiPlatform\Elasticsearch\Filter\TermFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(normalizationContext: ['groups' => ['user:read']])] +#[ApiFilter(TermFilter::class, properties: ['id', 'gender', 'age', 'firstName', 'tweets.id', 'tweets.date'])] +class User +{ + #[ApiProperty(identifier: true)] + #[Groups(['tweet:read', 'user:read'])] + private ?string $id = null; + + #[Groups(['tweet:read', 'user:read'])] + private ?string $gender = null; + + #[Groups(['tweet:read', 'user:read'])] + private ?int $age = null; + + #[Groups(['tweet:read', 'user:read'])] + private ?string $firstName = null; + + #[Groups(['tweet:read', 'user:read'])] + private ?string $lastName = null; + + #[Groups(['user:read'])] + private ?\DateTimeInterface $registeredAt = null; + + #[Groups(['user:read'])] + private array $tweets = []; + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $id): void + { + $this->id = $id; + } + + public function getGender(): ?string + { + return $this->gender; + } + + public function setGender(string $gender): void + { + $this->gender = $gender; + } + + public function getAge(): ?int + { + return $this->age; + } + + public function setAge(int $age): void + { + $this->age = $age; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): void + { + $this->firstName = $firstName; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): void + { + $this->lastName = $lastName; + } + + public function getRegisteredAt(): ?\DateTimeInterface + { + return $this->registeredAt; + } + + public function setRegisteredAt(\DateTimeInterface $registeredAt): void + { + $this->registeredAt = $registeredAt; + } + + public function getTweets(): array + { + return $this->tweets; + } + + public function setTweets(array $tweets): void + { + $this->tweets = $tweets; + } + + public function addTweet(Tweet $tweet): void + { + if (\in_array($tweet, $this->tweets, true)) { + return; + } + + $this->tweets[] = $tweet; + } + + public function removeTweet(Tweet $tweet): void + { + $index = array_search($tweet, $this->tweets, true); + + if (!\is_int($index)) { + return; + } + + array_splice($this->tweets, $index, 1); + } +} diff --git a/tests/Elasticsearch/Metadata/Document/DocumentMetadataTest.php b/src/Elasticsearch/Tests/Metadata/Document/DocumentMetadataTest.php similarity index 95% rename from tests/Elasticsearch/Metadata/Document/DocumentMetadataTest.php rename to src/Elasticsearch/Tests/Metadata/Document/DocumentMetadataTest.php index e2af9277485..a7c304bb9d0 100644 --- a/tests/Elasticsearch/Metadata/Document/DocumentMetadataTest.php +++ b/src/Elasticsearch/Tests/Metadata/Document/DocumentMetadataTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Metadata\Document; +namespace ApiPlatform\Elasticsearch\Tests\Metadata\Document; use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata; use PHPUnit\Framework\TestCase; diff --git a/tests/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Document/Factory/AttributeDocumentMetadataFactoryTest.php similarity index 96% rename from tests/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactoryTest.php rename to src/Elasticsearch/Tests/Metadata/Document/Factory/AttributeDocumentMetadataFactoryTest.php index 5ec7de31d3d..24e9e29f3be 100644 --- a/tests/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Document/Factory/AttributeDocumentMetadataFactoryTest.php @@ -11,18 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Metadata\Document\Factory; +namespace ApiPlatform\Elasticsearch\Tests\Metadata\Document\Factory; use ApiPlatform\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\AttributeDocumentMetadataFactory; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Document/Factory/CachedDocumentMetadataFactoryTest.php similarity index 98% rename from tests/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactoryTest.php rename to src/Elasticsearch/Tests/Metadata/Document/Factory/CachedDocumentMetadataFactoryTest.php index f8bb25af7f3..1b7cf6a3fc4 100644 --- a/tests/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Document/Factory/CachedDocumentMetadataFactoryTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Metadata\Document\Factory; +namespace ApiPlatform\Elasticsearch\Tests\Metadata\Document\Factory; use ApiPlatform\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\CachedDocumentMetadataFactory; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Document/Factory/CatDocumentMetadataFactoryTest.php similarity index 98% rename from tests/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactoryTest.php rename to src/Elasticsearch/Tests/Metadata/Document/Factory/CatDocumentMetadataFactoryTest.php index 96f23438e48..be3cba6d7a6 100644 --- a/tests/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Document/Factory/CatDocumentMetadataFactoryTest.php @@ -11,18 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Metadata\Document\Factory; +namespace ApiPlatform\Elasticsearch\Tests\Metadata\Document\Factory; use ApiPlatform\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\CatDocumentMetadataFactory; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use Elasticsearch\Client; use Elasticsearch\Common\Exceptions\Missing404Exception; use Elasticsearch\Namespaces\CatNamespace; diff --git a/tests/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Document/Factory/ConfiguredDocumentMetadataFactoryTest.php similarity index 96% rename from tests/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactoryTest.php rename to src/Elasticsearch/Tests/Metadata/Document/Factory/ConfiguredDocumentMetadataFactoryTest.php index 8680b0a84cf..a20454fc9a2 100644 --- a/tests/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Document/Factory/ConfiguredDocumentMetadataFactoryTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Metadata\Document\Factory; +namespace ApiPlatform\Elasticsearch\Tests\Metadata\Document\Factory; use ApiPlatform\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\ConfiguredDocumentMetadataFactory; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php similarity index 95% rename from tests/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php rename to src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php index 96529784ab1..d9899f1f82b 100644 --- a/tests/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php @@ -11,15 +11,15 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Metadata\Resource\Factory; +namespace ApiPlatform\Elasticsearch\Tests\Metadata\Resource\Factory; use ApiPlatform\Elasticsearch\Metadata\Resource\Factory\ElasticsearchProviderResourceMetadataCollectionFactory; use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; -use ApiPlatform\Tests\Fixtures\TestBundle\Metadata\Get; +use ApiPlatform\Metadata\Tests\Fixtures\Metadata\Get; use Elasticsearch\Client; use Elasticsearch\Common\Exceptions\Missing404Exception; use Elasticsearch\Namespaces\CatNamespace; diff --git a/tests/Elasticsearch/PaginatorTest.php b/src/Elasticsearch/Tests/PaginatorTest.php similarity index 98% rename from tests/Elasticsearch/PaginatorTest.php rename to src/Elasticsearch/Tests/PaginatorTest.php index dcacd115f11..c9fb655d7c2 100644 --- a/tests/Elasticsearch/PaginatorTest.php +++ b/src/Elasticsearch/Tests/PaginatorTest.php @@ -11,12 +11,12 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\DataProvider; +namespace ApiPlatform\Elasticsearch\Tests; use ApiPlatform\Elasticsearch\Paginator; use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\State\Pagination\PaginatorInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; diff --git a/tests/Elasticsearch/Serializer/DocumentNormalizerTest.php b/src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php similarity index 97% rename from tests/Elasticsearch/Serializer/DocumentNormalizerTest.php rename to src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php index 1361b6b7054..554a5dd84ae 100644 --- a/tests/Elasticsearch/Serializer/DocumentNormalizerTest.php +++ b/src/Elasticsearch/Tests/Serializer/DocumentNormalizerTest.php @@ -11,15 +11,15 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Serializer; +namespace ApiPlatform\Elasticsearch\Tests\Serializer; use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Exception\LogicException; diff --git a/tests/Elasticsearch/Serializer/ItemNormalizerTest.php b/src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php similarity index 99% rename from tests/Elasticsearch/Serializer/ItemNormalizerTest.php rename to src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php index a33b2996a95..2bc30ecbef7 100644 --- a/tests/Elasticsearch/Serializer/ItemNormalizerTest.php +++ b/src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Serializer; +namespace ApiPlatform\Elasticsearch\Tests\Serializer; use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Elasticsearch\Serializer\ItemNormalizer; diff --git a/tests/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverterTest.php b/src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php similarity index 96% rename from tests/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverterTest.php rename to src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php index 3ca9db1fc32..e1c4ab31d6a 100644 --- a/tests/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverterTest.php +++ b/src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Serializer\NameConverter; +namespace ApiPlatform\Elasticsearch\Tests\Serializer\NameConverter; use ApiPlatform\Elasticsearch\Serializer\NameConverter\InnerFieldsNameConverter; use PHPUnit\Framework\TestCase; diff --git a/tests/Elasticsearch/State/CollectionProviderTest.php b/src/Elasticsearch/Tests/State/CollectionProviderTest.php similarity index 97% rename from tests/Elasticsearch/State/CollectionProviderTest.php rename to src/Elasticsearch/Tests/State/CollectionProviderTest.php index d6975ba45d3..a3f443910c6 100644 --- a/tests/Elasticsearch/State/CollectionProviderTest.php +++ b/src/Elasticsearch/Tests/State/CollectionProviderTest.php @@ -11,17 +11,17 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\State; +namespace ApiPlatform\Elasticsearch\Tests\State; use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; use ApiPlatform\Elasticsearch\Paginator; use ApiPlatform\Elasticsearch\State\CollectionProvider; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use Elasticsearch\Client; use PHPUnit\Framework\TestCase; use Prophecy\Argument; diff --git a/tests/Elasticsearch/State/ItemProviderTest.php b/src/Elasticsearch/Tests/State/ItemProviderTest.php similarity index 97% rename from tests/Elasticsearch/State/ItemProviderTest.php rename to src/Elasticsearch/Tests/State/ItemProviderTest.php index 26635ed96a8..8f19d886e10 100644 --- a/tests/Elasticsearch/State/ItemProviderTest.php +++ b/src/Elasticsearch/Tests/State/ItemProviderTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\State; +namespace ApiPlatform\Elasticsearch\Tests\State; use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Elasticsearch\State\ItemProvider; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\Get; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use Elasticsearch\Client; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Elasticsearch/Util/ElasticsearchVersionTest.php b/src/Elasticsearch/Tests/Util/ElasticsearchVersionTest.php similarity index 95% rename from tests/Elasticsearch/Util/ElasticsearchVersionTest.php rename to src/Elasticsearch/Tests/Util/ElasticsearchVersionTest.php index d2a7bcc89c0..ea7a8c25f85 100644 --- a/tests/Elasticsearch/Util/ElasticsearchVersionTest.php +++ b/src/Elasticsearch/Tests/Util/ElasticsearchVersionTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Util; +namespace ApiPlatform\Elasticsearch\Tests\Util; use ApiPlatform\Elasticsearch\Util\ElasticsearchVersion; use PHPUnit\Framework\TestCase; diff --git a/tests/Elasticsearch/Util/FieldDatatypeTraitTest.php b/src/Elasticsearch/Tests/Util/FieldDatatypeTraitTest.php similarity index 95% rename from tests/Elasticsearch/Util/FieldDatatypeTraitTest.php rename to src/Elasticsearch/Tests/Util/FieldDatatypeTraitTest.php index e3959bd0ad9..4979c983e31 100644 --- a/tests/Elasticsearch/Util/FieldDatatypeTraitTest.php +++ b/src/Elasticsearch/Tests/Util/FieldDatatypeTraitTest.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Elasticsearch\Util; +namespace ApiPlatform\Elasticsearch\Tests\Util; -use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Elasticsearch\Util\FieldDatatypeTrait; -use ApiPlatform\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyInfo\Type; diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 99894eb7109..b4a238e148d 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -13,9 +13,9 @@ namespace ApiPlatform\Elasticsearch\Util; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\PropertyInfo\Type; /** @@ -59,30 +59,27 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s return null; } - // TODO: 3.0 allow multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - - if (null === $type) { - return null; - } - - if ( - Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($nextResourceClass = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($nextResourceClass) - ) { - $nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties)); - - return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath"; - } - - if ( - null !== ($type = $type->getCollectionValueTypes()[0] ?? null) - && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - return $currentProperty; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + foreach ($types as $type) { + if ( + Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() + && null !== ($nextResourceClass = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($nextResourceClass) + ) { + $nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties)); + + return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath"; + } + + if ( + null !== ($type = $type->getCollectionValueTypes()[0] ?? null) + && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() + && null !== ($className = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + return $currentProperty; + } } return null; diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json new file mode 100644 index 00000000000..5fe72aca5aa --- /dev/null +++ b/src/Elasticsearch/composer.json @@ -0,0 +1,82 @@ +{ + "name": "api-platform/elasticseach", + "description": "Elasticsearch support", + "type": "library", + "keywords": [ + "Filter", + "Elasticsearch" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", + "api-platform/serializer": "*@dev || ^3.1", + "elasticsearch/elasticsearch": "^7.11.0", + "symfony/cache": "^6.1", + "symfony/console": "^6.2", + "symfony/property-info": "^6.1", + "symfony/serializer": "^6.1", + "symfony/uid": "^6.1", + "symfony/property-access": "^6.1" + }, + "conflict": { + "elasticsearch/elasticsearch": ">=8.0" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Elasticsearch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + }, + { + "type": "path", + "url": "../State" + }, + { + "type": "path", + "url": "../Serializer" + } + ] +} diff --git a/src/Elasticsearch/phpunit.xml.dist b/src/Elasticsearch/phpunit.xml.dist new file mode 100644 index 00000000000..0b1b38b4a5b --- /dev/null +++ b/src/Elasticsearch/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + + diff --git a/src/Exception/InvalidIdentifierException.php b/src/Exception/InvalidIdentifierException.php index 961aad89db1..d77354ae30a 100644 --- a/src/Exception/InvalidIdentifierException.php +++ b/src/Exception/InvalidIdentifierException.php @@ -18,6 +18,6 @@ * * @author Antoine Bluchet */ -final class InvalidIdentifierException extends \Exception implements ExceptionInterface +class InvalidIdentifierException extends \Exception implements ExceptionInterface { } diff --git a/src/Exception/InvalidUriVariableException.php b/src/Exception/InvalidUriVariableException.php index f2e44a58397..5aedb5839a7 100644 --- a/src/Exception/InvalidUriVariableException.php +++ b/src/Exception/InvalidUriVariableException.php @@ -18,6 +18,6 @@ * * @author Antoine Bluchet */ -final class InvalidUriVariableException extends \Exception implements ExceptionInterface +class InvalidUriVariableException extends \Exception implements ExceptionInterface { } diff --git a/src/GraphQl/.gitignore b/src/GraphQl/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/GraphQl/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/GraphQl/Executor.php b/src/GraphQl/Executor.php index e1fa9b5ee71..701ba59244a 100644 --- a/src/GraphQl/Executor.php +++ b/src/GraphQl/Executor.php @@ -16,6 +16,8 @@ use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; use GraphQL\Type\Schema; +use GraphQL\Validator\DocumentValidator; +use GraphQL\Validator\Rules\DisableIntrospection; /** * Wrapper for the GraphQL facade. @@ -24,6 +26,15 @@ */ final class Executor implements ExecutorInterface { + public function __construct(private readonly bool $graphQlIntrospectionEnabled = true) + { + DocumentValidator::addRule( + new DisableIntrospection( + $this->graphQlIntrospectionEnabled ? DisableIntrospection::DISABLED : DisableIntrospection::ENABLED + ) + ); + } + /** * {@inheritdoc} */ diff --git a/src/GraphQl/LICENSE b/src/GraphQl/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/GraphQl/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/GraphQl/README.md b/src/GraphQl/README.md new file mode 100644 index 00000000000..10b0b8c8ae1 --- /dev/null +++ b/src/GraphQl/README.md @@ -0,0 +1,3 @@ +# API Platform - GraphQL + +Build GraphQL API endpoints diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index ae4c723e301..6cbac320d6c 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -19,7 +19,7 @@ use ApiPlatform\GraphQl\Resolver\Stage\SecurityStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; use ApiPlatform\Metadata\GraphQl\Operation; -use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index 71bb3852518..7a17ecf1e18 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -25,7 +25,7 @@ use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php index 40748a24242..a08f909bf31 100644 --- a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php @@ -21,7 +21,7 @@ use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; diff --git a/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php index 7bb3acbc81e..2ad3f898ec0 100644 --- a/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php @@ -20,7 +20,7 @@ use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Metadata\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; /** diff --git a/src/GraphQl/Resolver/ResourceFieldResolver.php b/src/GraphQl/Resolver/ResourceFieldResolver.php index 98225350b7c..54b08218928 100644 --- a/src/GraphQl/Resolver/ResourceFieldResolver.php +++ b/src/GraphQl/Resolver/ResourceFieldResolver.php @@ -13,9 +13,9 @@ namespace ApiPlatform\GraphQl\Resolver; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use GraphQL\Type\Definition\ResolveInfo; diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index fcb80fc4b0a..9ab1007eab0 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -13,15 +13,13 @@ namespace ApiPlatform\GraphQl\Resolver\Stage; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Exception\ItemNotFoundException; use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\GraphQl\Operation; -use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Util\ArrayTrait; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -32,8 +30,6 @@ */ final class ReadStage implements ReadStageInterface { - use ArrayTrait; - use ClassInfoTrait; use IdentifierTrait; public function __construct(private readonly IriConverterInterface $iriConverter, private readonly ProviderInterface $provider, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private readonly string $nestingSeparator) @@ -133,4 +129,62 @@ private function getNormalizedFilters(array $args): array return $filters; } + + public function isSequentialArrayOfArrays(array $array): bool + { + if (!$this->isSequentialArray($array)) { + return false; + } + + return $this->arrayContainsOnly($array, 'array'); + } + + public function isSequentialArray(array $array): bool + { + if ([] === $array) { + return false; + } + + return array_is_list($array); + } + + public function arrayContainsOnly(array $array, string $type): bool + { + return $array === array_filter($array, static fn ($item): bool => $type === \gettype($item)); + } + + /** + * Get class name of the given object. + */ + private function getObjectClass(object $object): string + { + return $this->getRealClassName($object::class); + } + + /** + * Get the real class name of a class name that could be a proxy. + */ + private function getRealClassName(string $className): string + { + // __CG__: Doctrine Common Marker for Proxy (ODM < 2.0 and ORM < 3.0) + // __PM__: Ocramius Proxy Manager (ODM >= 2.0) + $positionCg = strrpos($className, '\\__CG__\\'); + $positionPm = strrpos($className, '\\__PM__\\'); + + if (false === $positionCg && false === $positionPm) { + return $className; + } + + if (false !== $positionCg) { + return substr($className, $positionCg + 8); + } + + $className = ltrim($className, '\\'); + + return substr( + $className, + 8 + $positionPm, + strrpos($className, '\\') - ($positionPm + 8) + ); + } } diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index d2d8da58aa1..551cedcdb83 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -13,13 +13,13 @@ namespace ApiPlatform\GraphQl\Serializer; -use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php index 5c2cbba2e02..386cc8d1880 100644 --- a/src/GraphQl/Serializer/ObjectNormalizer.php +++ b/src/GraphQl/Serializer/ObjectNormalizer.php @@ -14,7 +14,7 @@ namespace ApiPlatform\GraphQl\Serializer; use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Exception\UnexpectedValueException; diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index b29372bcd88..648f8424af4 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -13,14 +13,14 @@ namespace ApiPlatform\GraphQl\Subscription; -use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use ApiPlatform\Util\SortTrait; +use ApiPlatform\Metadata\Util\SortTrait; use GraphQL\Type\Definition\ResolveInfo; use Psr\Cache\CacheItemPoolInterface; diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/src/GraphQl/Tests/Action/EntrypointActionTest.php similarity index 99% rename from tests/GraphQl/Action/EntrypointActionTest.php rename to src/GraphQl/Tests/Action/EntrypointActionTest.php index 28abb86b7b7..14a4d460deb 100644 --- a/tests/GraphQl/Action/EntrypointActionTest.php +++ b/src/GraphQl/Tests/Action/EntrypointActionTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Action; +namespace ApiPlatform\GraphQl\Tests\Action; use ApiPlatform\GraphQl\Action\EntrypointAction; use ApiPlatform\GraphQl\Action\GraphiQlAction; diff --git a/tests/GraphQl/Action/Fixtures/test.gif b/src/GraphQl/Tests/Action/Fixtures/test.gif similarity index 100% rename from tests/GraphQl/Action/Fixtures/test.gif rename to src/GraphQl/Tests/Action/Fixtures/test.gif diff --git a/tests/GraphQl/Action/GraphQlPlaygroundActionTest.php b/src/GraphQl/Tests/Action/GraphQlPlaygroundActionTest.php similarity index 97% rename from tests/GraphQl/Action/GraphQlPlaygroundActionTest.php rename to src/GraphQl/Tests/Action/GraphQlPlaygroundActionTest.php index c0231c64152..0e8ccd6e143 100644 --- a/tests/GraphQl/Action/GraphQlPlaygroundActionTest.php +++ b/src/GraphQl/Tests/Action/GraphQlPlaygroundActionTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Action; +namespace ApiPlatform\GraphQl\Tests\Action; use ApiPlatform\GraphQl\Action\GraphQlPlaygroundAction; use PHPUnit\Framework\TestCase; diff --git a/tests/GraphQl/Action/GraphiQlActionTest.php b/src/GraphQl/Tests/Action/GraphiQlActionTest.php similarity index 97% rename from tests/GraphQl/Action/GraphiQlActionTest.php rename to src/GraphQl/Tests/Action/GraphiQlActionTest.php index a31aa6a5f12..b2249a7cb9a 100644 --- a/tests/GraphQl/Action/GraphiQlActionTest.php +++ b/src/GraphQl/Tests/Action/GraphiQlActionTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Action; +namespace ApiPlatform\GraphQl\Tests\Action; use ApiPlatform\GraphQl\Action\GraphiQlAction; use PHPUnit\Framework\TestCase; diff --git a/src/GraphQl/Tests/ExecutorTest.php b/src/GraphQl/Tests/ExecutorTest.php new file mode 100644 index 00000000000..43cc420dded --- /dev/null +++ b/src/GraphQl/Tests/ExecutorTest.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\GraphQl\Tests; + +use ApiPlatform\GraphQl\Executor; +use GraphQL\Validator\DocumentValidator; +use GraphQL\Validator\Rules\DisableIntrospection; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Verger + */ +class ExecutorTest extends TestCase +{ + public function testEnableIntrospectionQuery(): void + { + $executor = new Executor(true); + + $expected = new DisableIntrospection(DisableIntrospection::DISABLED); + $this->assertEquals($expected, DocumentValidator::getRule(DisableIntrospection::class)); + } + + public function testDisableIntrospectionQuery(): void + { + $executor = new Executor(false); + + $expected = new DisableIntrospection(DisableIntrospection::ENABLED); + $this->assertEquals($expected, DocumentValidator::getRule(DisableIntrospection::class)); + } +} diff --git a/src/GraphQl/Tests/Fixtures/ApiResource/Dummy.php b/src/GraphQl/Tests/Fixtures/ApiResource/Dummy.php new file mode 100644 index 00000000000..aac4550aac2 --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/ApiResource/Dummy.php @@ -0,0 +1,215 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; + +/** + * Dummy. + * + * @author Kévin Dunglas + */ +class Dummy +{ + /** + * @var int|null The id + */ + private $id; + + /** + * @var string The dummy name + */ + #[ApiProperty(iris: ['https://schema.org/name'])] + private string $name; + + /** + * @var string|null The dummy name alias + */ + #[ApiProperty(iris: ['https://schema.org/alternateName'])] + private $alias; + + /** + * @var array foo + */ + private ?array $foo = null; + + /** + * @var string|null A short description of the item + */ + #[ApiProperty(iris: ['https://schema.org/description'])] + public $description; + + /** + * @var string|null A dummy + */ + public $dummy; + + /** + * @var bool|null A dummy boolean + */ + public ?bool $dummyBoolean = null; + /** + * @var \DateTime|null A dummy date + */ + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + public $dummyDate; + + /** + * @var float|null A dummy float + */ + public $dummyFloat; + + /** + * @var string|null A dummy price + */ + public $dummyPrice; + + /** + * @var array|null serialize data + */ + public $jsonData = []; + + /** + * @var array|null + */ + public $arrayData = []; + + /** + * @var string|null + */ + public $nameConverted; + + public static function staticMethod(): void + { + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setAlias($alias): void + { + $this->alias = $alias; + } + + public function getAlias() + { + return $this->alias; + } + + public function setDescription($description): void + { + $this->description = $description; + } + + public function getDescription() + { + return $this->description; + } + + public function fooBar($baz): void + { + } + + public function getFoo(): ?array + { + return $this->foo; + } + + public function setFoo(array $foo = null): void + { + $this->foo = $foo; + } + + public function setDummyDate(\DateTime $dummyDate = null): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function setDummyPrice($dummyPrice) + { + $this->dummyPrice = $dummyPrice; + + return $this; + } + + public function getDummyPrice() + { + return $this->dummyPrice; + } + + public function setJsonData($jsonData): void + { + $this->jsonData = $jsonData; + } + + public function getJsonData() + { + return $this->jsonData; + } + + public function setArrayData($arrayData): void + { + $this->arrayData = $arrayData; + } + + public function getArrayData() + { + return $this->arrayData; + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean($dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function setDummy($dummy = null): void + { + $this->dummy = $dummy; + } + + public function getDummy() + { + return $this->dummy; + } +} diff --git a/src/GraphQl/Tests/Fixtures/Enum/GamePlayMode.php b/src/GraphQl/Tests/Fixtures/Enum/GamePlayMode.php new file mode 100644 index 00000000000..7b51a00b8bd --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/Enum/GamePlayMode.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Fixtures\Enum; + +use ApiPlatform\Metadata\Operation; + +enum GamePlayMode: string +{ + /* Co-operative games, where you play on the same team with friends. */ + case CO_OP = 'CoOp'; + + /* Requiring or allowing multiple human players to play simultaneously. */ + case MULTI_PLAYER = 'MultiPlayer'; + + /* Which is played by a lone player. */ + case SINGLE_PLAYER = 'SinglePlayer'; + + public function getId(): string + { + return $this->name; + } + + public static function getCase(Operation $operation, array $uriVariables): GamePlayMode + { + $name = $uriVariables['id'] ?? null; + + return \constant(self::class."::$name"); + } + + public static function getCases(): array + { + return self::cases(); + } +} diff --git a/src/GraphQl/Tests/Fixtures/Enum/GenderTypeEnum.php b/src/GraphQl/Tests/Fixtures/Enum/GenderTypeEnum.php new file mode 100644 index 00000000000..df7a1549e87 --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/Enum/GenderTypeEnum.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\GraphQl\Tests\Fixtures\Enum; + +use ApiPlatform\Metadata\ApiProperty; + +/** + * An enumeration of genders. + */ +enum GenderTypeEnum: string +{ + /* The male gender. */ + case MALE = 'male'; + + #[ApiProperty(description: 'The female gender.')] + case FEMALE = 'female'; +} diff --git a/src/GraphQl/Tests/Fixtures/Serializer/NameConverter/CustomConverter.php b/src/GraphQl/Tests/Fixtures/Serializer/NameConverter/CustomConverter.php new file mode 100644 index 00000000000..28a97625c20 --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/Serializer/NameConverter/CustomConverter.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Fixtures\Serializer\NameConverter; + +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +/** + * Custom converter that will only convert a property named "nameConverted" + * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. + */ +class CustomConverter extends CamelCaseToSnakeCaseNameConverter +{ + public function normalize(string $propertyName): string + { + return 'nameConverted' === $propertyName ? parent::normalize($propertyName) : $propertyName; + } + + public function denormalize(string $propertyName): string + { + return 'name_converted' === $propertyName ? parent::denormalize($propertyName) : $propertyName; + } +} diff --git a/src/GraphQl/Tests/Fixtures/Type/Definition/DateTimeType.php b/src/GraphQl/Tests/Fixtures/Type/Definition/DateTimeType.php new file mode 100644 index 00000000000..2a0e475796b --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/Type/Definition/DateTimeType.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\GraphQl\Tests\Fixtures\Type\Definition; + +use ApiPlatform\GraphQl\Type\Definition\TypeInterface; +use GraphQL\Error\Error; +use GraphQL\Language\AST\Node; +use GraphQL\Language\AST\StringValueNode; +use GraphQL\Type\Definition\ScalarType; +use GraphQL\Utils\Utils; + +/** + * Represents a DateTime type. + * + * @author Alan Poulain + */ +final class DateTimeType extends ScalarType implements TypeInterface +{ + public function __construct() + { + $this->name = \DateTime::class; + $this->description = 'The `DateTime` scalar type represents time data.'; + + parent::__construct(); + } + + public function getName(): string + { + return $this->name; + } + + /** + * {@inheritdoc} + */ + public function serialize($value): string + { + // Already serialized. + if (\is_string($value)) { + // Should be better in a custom normalizer. + return (new \DateTime($value))->format('Y-m-d'); + } + + if (!($value instanceof \DateTime)) { + throw new Error(sprintf('Value must be an instance of DateTime to be represented by DateTime: %s', Utils::printSafe($value))); + } + + return $value->format(\DateTime::ATOM); + } + + /** + * {@inheritdoc} + */ + public function parseValue($value): string + { + if (!\is_string($value)) { + throw new Error(sprintf('DateTime cannot represent non string value: %s', Utils::printSafeJson($value))); + } + + if (false === \DateTime::createFromFormat(\DateTime::ATOM, $value)) { + throw new Error(sprintf('DateTime cannot represent non date value: %s', Utils::printSafeJson($value))); + } + + // Will be denormalized into a \DateTime. + return $value; + } + + /** + * {@inheritdoc} + */ + public function parseLiteral(Node $valueNode, array $variables = null): string + { + if ($valueNode instanceof StringValueNode && false !== \DateTime::createFromFormat(\DateTime::ATOM, $valueNode->value)) { + return $valueNode->value; + } + + // Intentionally without message, as all information already in wrapped Exception + throw new \Exception(); + } +} diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/CollectionResolverFactoryTest.php similarity index 99% rename from tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php rename to src/GraphQl/Tests/Resolver/Factory/CollectionResolverFactoryTest.php index 33f5cbb1c4e..53272f813d5 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/src/GraphQl/Tests/Resolver/Factory/CollectionResolverFactoryTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Factory; +namespace ApiPlatform\GraphQl\Tests\Resolver\Factory; use ApiPlatform\GraphQl\Resolver\Factory\CollectionResolverFactory; use ApiPlatform\GraphQl\Resolver\Stage\ReadStageInterface; diff --git a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/ItemMutationResolverFactoryTest.php similarity index 99% rename from tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php rename to src/GraphQl/Tests/Resolver/Factory/ItemMutationResolverFactoryTest.php index b46add3ec07..c7220499409 100644 --- a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php +++ b/src/GraphQl/Tests/Resolver/Factory/ItemMutationResolverFactoryTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Factory; +namespace ApiPlatform\GraphQl\Tests\Resolver\Factory; use ApiPlatform\GraphQl\Resolver\Factory\ItemMutationResolverFactory; use ApiPlatform\GraphQl\Resolver\Stage\DeserializeStageInterface; @@ -22,8 +22,8 @@ use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\ValidateStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\WriteStageInterface; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; use ApiPlatform\Metadata\GraphQl\Mutation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; diff --git a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/ItemResolverFactoryTest.php similarity index 99% rename from tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php rename to src/GraphQl/Tests/Resolver/Factory/ItemResolverFactoryTest.php index 6fcbfdb7bda..c57e49f218b 100644 --- a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php +++ b/src/GraphQl/Tests/Resolver/Factory/ItemResolverFactoryTest.php @@ -11,15 +11,15 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Factory; +namespace ApiPlatform\GraphQl\Tests\Resolver\Factory; use ApiPlatform\GraphQl\Resolver\Factory\ItemResolverFactory; use ApiPlatform\GraphQl\Resolver\Stage\ReadStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\SecurityPostDenormalizeStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\SecurityStageInterface; use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; use ApiPlatform\Metadata\GraphQl\Query; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php similarity index 99% rename from tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php rename to src/GraphQl/Tests/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php index 888e84a3711..5d455a6df0f 100644 --- a/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php +++ b/src/GraphQl/Tests/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Factory; +namespace ApiPlatform\GraphQl\Tests\Resolver\Factory; use ApiPlatform\GraphQl\Resolver\Factory\ItemSubscriptionResolverFactory; use ApiPlatform\GraphQl\Resolver\Stage\ReadStageInterface; diff --git a/tests/GraphQl/Resolver/ResourceFieldResolverTest.php b/src/GraphQl/Tests/Resolver/ResourceFieldResolverTest.php similarity index 95% rename from tests/GraphQl/Resolver/ResourceFieldResolverTest.php rename to src/GraphQl/Tests/Resolver/ResourceFieldResolverTest.php index cb95aa23b93..f6c435876cf 100644 --- a/tests/GraphQl/Resolver/ResourceFieldResolverTest.php +++ b/src/GraphQl/Tests/Resolver/ResourceFieldResolverTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver; +namespace ApiPlatform\GraphQl\Tests\Resolver; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\GraphQl\Resolver\ResourceFieldResolver; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\ObjectType; diff --git a/tests/GraphQl/Resolver/Stage/DeserializeStageTest.php b/src/GraphQl/Tests/Resolver/Stage/DeserializeStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/DeserializeStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/DeserializeStageTest.php index bff6f4f26a0..174dbe06f92 100644 --- a/tests/GraphQl/Resolver/Stage/DeserializeStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/DeserializeStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\DeserializeStage; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/src/GraphQl/Tests/Resolver/Stage/ReadStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/ReadStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/ReadStageTest.php index a5d101405c9..aa28e6aed75 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/ReadStageTest.php @@ -11,17 +11,17 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Exception\ItemNotFoundException; use ApiPlatform\GraphQl\Resolver\Stage\ReadStage; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; diff --git a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SecurityPostDenormalizeStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/SecurityPostDenormalizeStageTest.php index aa956cbb5cc..538284ec231 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SecurityPostDenormalizeStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\SecurityPostDenormalizeStage; use ApiPlatform\Metadata\GraphQl\Operation; diff --git a/tests/GraphQl/Resolver/Stage/SecurityPostValidationStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SecurityPostValidationStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/SecurityPostValidationStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/SecurityPostValidationStageTest.php index b75b84a33e3..1d39715e076 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityPostValidationStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SecurityPostValidationStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\SecurityPostValidationStage; use ApiPlatform\Metadata\GraphQl\Operation; diff --git a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SecurityStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/SecurityStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/SecurityStageTest.php index 089209920e8..58f06f93d81 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SecurityStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\SecurityStage; use ApiPlatform\Metadata\GraphQl\Operation; diff --git a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php similarity index 99% rename from tests/GraphQl/Resolver/Stage/SerializeStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php index 52da720b61d..af72a28d6d1 100644 --- a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\SerializeStage; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; diff --git a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php b/src/GraphQl/Tests/Resolver/Stage/ValidateStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/ValidateStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/ValidateStageTest.php index 07a52c18e05..dab437b9b09 100644 --- a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/ValidateStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\ValidateStage; use ApiPlatform\Metadata\GraphQl\Operation; diff --git a/tests/GraphQl/Resolver/Stage/WriteStageTest.php b/src/GraphQl/Tests/Resolver/Stage/WriteStageTest.php similarity index 98% rename from tests/GraphQl/Resolver/Stage/WriteStageTest.php rename to src/GraphQl/Tests/Resolver/Stage/WriteStageTest.php index ff076a30e18..25544062faf 100644 --- a/tests/GraphQl/Resolver/Stage/WriteStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/WriteStageTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Stage; +namespace ApiPlatform\GraphQl\Tests\Resolver\Stage; use ApiPlatform\GraphQl\Resolver\Stage\WriteStage; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; diff --git a/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php b/src/GraphQl/Tests/Resolver/Util/IdentifierTraitTest.php similarity index 97% rename from tests/GraphQl/Resolver/Util/IdentifierTraitTest.php rename to src/GraphQl/Tests/Resolver/Util/IdentifierTraitTest.php index ec289ae2875..54b87fa8138 100644 --- a/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php +++ b/src/GraphQl/Tests/Resolver/Util/IdentifierTraitTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Resolver\Util; +namespace ApiPlatform\GraphQl\Tests\Resolver\Util; use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; use PHPUnit\Framework\TestCase; diff --git a/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php b/src/GraphQl/Tests/Serializer/Exception/ErrorNormalizerTest.php similarity index 95% rename from tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php rename to src/GraphQl/Tests/Serializer/Exception/ErrorNormalizerTest.php index defb3c39df6..c2f23715f7b 100644 --- a/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/Exception/ErrorNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Serializer\Exception; +namespace ApiPlatform\GraphQl\Tests\Serializer\Exception; use ApiPlatform\GraphQl\Serializer\Exception\ErrorNormalizer; use GraphQL\Error\Error; diff --git a/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php b/src/GraphQl/Tests/Serializer/Exception/HttpExceptionNormalizerTest.php similarity index 97% rename from tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php rename to src/GraphQl/Tests/Serializer/Exception/HttpExceptionNormalizerTest.php index 417eb4d1b53..9a66078a65e 100644 --- a/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/Exception/HttpExceptionNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Serializer\Exception; +namespace ApiPlatform\GraphQl\Tests\Serializer\Exception; use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer; use GraphQL\Error\Error; diff --git a/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php b/src/GraphQl/Tests/Serializer/Exception/RuntimeExceptionNormalizerTest.php similarity index 96% rename from tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php rename to src/GraphQl/Tests/Serializer/Exception/RuntimeExceptionNormalizerTest.php index c4d92a8ffc2..aa96bcbe18a 100644 --- a/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/Exception/RuntimeExceptionNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Serializer\Exception; +namespace ApiPlatform\GraphQl\Tests\Serializer\Exception; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer; use GraphQL\Error\Error; diff --git a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php b/src/GraphQl/Tests/Serializer/Exception/ValidationExceptionNormalizerTest.php similarity index 97% rename from tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php rename to src/GraphQl/Tests/Serializer/Exception/ValidationExceptionNormalizerTest.php index 15ac2fdbc84..c1fa45ddf7f 100644 --- a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/Exception/ValidationExceptionNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Serializer\Exception; +namespace ApiPlatform\GraphQl\Tests\Serializer\Exception; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer; use ApiPlatform\Symfony\Validator\Exception\ValidationException; diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php similarity index 96% rename from tests/GraphQl/Serializer/ItemNormalizerTest.php rename to src/GraphQl/Tests/Serializer/ItemNormalizerTest.php index 06929888908..f619093bcab 100644 --- a/tests/GraphQl/Serializer/ItemNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php @@ -11,18 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Serializer; +namespace ApiPlatform\GraphQl\Tests\Serializer; -use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php b/src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php similarity index 98% rename from tests/GraphQl/Serializer/SerializerContextBuilderTest.php rename to src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php index b58874fb276..73f7ba9d107 100644 --- a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php +++ b/src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Serializer; +namespace ApiPlatform\GraphQl\Tests\Serializer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder; +use ApiPlatform\GraphQl\Tests\Fixtures\Serializer\NameConverter\CustomConverter; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; -use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; diff --git a/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php b/src/GraphQl/Tests/Subscription/MercureSubscriptionIriGeneratorTest.php similarity index 98% rename from tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php rename to src/GraphQl/Tests/Subscription/MercureSubscriptionIriGeneratorTest.php index c2c7f9fa5e0..b5ff9735183 100644 --- a/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php +++ b/src/GraphQl/Tests/Subscription/MercureSubscriptionIriGeneratorTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Subscription; +namespace ApiPlatform\GraphQl\Tests\Subscription; use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGenerator; use PHPUnit\Framework\TestCase; diff --git a/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php b/src/GraphQl/Tests/Subscription/SubscriptionIdentifierGeneratorTest.php similarity index 98% rename from tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php rename to src/GraphQl/Tests/Subscription/SubscriptionIdentifierGeneratorTest.php index e20c2ed0650..fcdb4c58c17 100644 --- a/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php +++ b/src/GraphQl/Tests/Subscription/SubscriptionIdentifierGeneratorTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Subscription; +namespace ApiPlatform\GraphQl\Tests\Subscription; use ApiPlatform\GraphQl\Subscription\SubscriptionIdentifierGenerator; use PHPUnit\Framework\TestCase; diff --git a/tests/GraphQl/Subscription/SubscriptionManagerTest.php b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php similarity index 98% rename from tests/GraphQl/Subscription/SubscriptionManagerTest.php rename to src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php index 83dd4730c7d..b8fcbd22e3f 100644 --- a/tests/GraphQl/Subscription/SubscriptionManagerTest.php +++ b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php @@ -11,19 +11,19 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Subscription; +namespace ApiPlatform\GraphQl\Tests\Subscription; -use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionIdentifierGeneratorInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionManager; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/GraphQl/Type/Definition/IterableTypeTest.php b/src/GraphQl/Tests/Type/Definition/IterableTypeTest.php similarity index 98% rename from tests/GraphQl/Type/Definition/IterableTypeTest.php rename to src/GraphQl/Tests/Type/Definition/IterableTypeTest.php index 1ba2540590b..854079b58b9 100644 --- a/tests/GraphQl/Type/Definition/IterableTypeTest.php +++ b/src/GraphQl/Tests/Type/Definition/IterableTypeTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type\Definition; +namespace ApiPlatform\GraphQl\Tests\Type\Definition; use ApiPlatform\GraphQl\Type\Definition\IterableType; use GraphQL\Error\Error; diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php similarity index 97% rename from tests/GraphQl/Type/FieldsBuilderTest.php rename to src/GraphQl/Tests/Type/FieldsBuilderTest.php index 53ecd69bac4..7cdade600a4 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -11,17 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type; +namespace ApiPlatform\GraphQl\Tests\Type; -use ApiPlatform\Api\FilterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; +use ApiPlatform\GraphQl\Tests\Fixtures\Enum\GenderTypeEnum; +use ApiPlatform\GraphQl\Tests\Fixtures\Serializer\NameConverter\CustomConverter; use ApiPlatform\GraphQl\Type\FieldsBuilder; use ApiPlatform\GraphQl\Type\TypeBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeConverterInterface; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; @@ -32,9 +33,8 @@ use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\Pagination\Pagination; -use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; -use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; @@ -139,7 +139,8 @@ public static function itemQueryFieldsProvider(): array { return [ 'no resource field configuration' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action'), [], null, null, []], - 'nested item query' => ['resourceClass', (new Query())->withNested(true)->withClass('resourceClass')->withName('action')->withShortName('ShortName'), [], new ObjectType(['name' => 'item', 'fields' => []]), function (): void {}, []], + 'nested item query' => ['resourceClass', (new Query())->withNested(true)->withClass('resourceClass')->withName('action')->withShortName('ShortName'), [], new ObjectType(['name' => 'item', 'fields' => []]), function (): void { + }, []], 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], GraphQLType::string(), null, [ 'actionShortName' => [ @@ -233,7 +234,8 @@ public static function collectionQueryFieldsProvider(): array { return [ 'no resource field configuration' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action'), [], null, null, []], - 'nested collection query' => ['resourceClass', (new QueryCollection())->withNested(true)->withClass('resourceClass')->withName('action')->withShortName('ShortName'), [], GraphQLType::listOf(new ObjectType(['name' => 'collection', 'fields' => []])), function (): void {}, []], + 'nested collection query' => ['resourceClass', (new QueryCollection())->withNested(true)->withClass('resourceClass')->withName('action')->withShortName('ShortName'), [], GraphQLType::listOf(new ObjectType(['name' => 'collection', 'fields' => []])), function (): void { + }, []], 'nominal collection case with deprecation reason and description' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection', 'fields' => []])), $resolver = function (): void { }, [ @@ -700,6 +702,22 @@ public static function resourceObjectTypeFieldsProvider(): iterable 'clientMutationId' => GraphQLType::string(), ], ]; + yield 'custom mutation' => ['resourceClass', (new Mutation())->withResolver('resolver')->withName('mutation'), + [ + 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withDescription('propertyBool description')->withReadable(false)->withWritable(true), + ], + true, 0, null, + [ + 'propertyBool' => [ + 'type' => GraphQLType::nonNull(GraphQLType::string()), + 'description' => 'propertyBool description', + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + 'clientMutationId' => GraphQLType::string(), + ], + ]; yield 'mutation nested input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), diff --git a/tests/GraphQl/Type/SchemaBuilderTest.php b/src/GraphQl/Tests/Type/SchemaBuilderTest.php similarity index 99% rename from tests/GraphQl/Type/SchemaBuilderTest.php rename to src/GraphQl/Tests/Type/SchemaBuilderTest.php index 427d76a7d30..8428663f50f 100644 --- a/tests/GraphQl/Type/SchemaBuilderTest.php +++ b/src/GraphQl/Tests/Type/SchemaBuilderTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type; +namespace ApiPlatform\GraphQl\Tests\Type; use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; use ApiPlatform\GraphQl\Type\SchemaBuilder; diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/src/GraphQl/Tests/Type/TypeBuilderTest.php similarity index 99% rename from tests/GraphQl/Type/TypeBuilderTest.php rename to src/GraphQl/Tests/Type/TypeBuilderTest.php index 02532b8629b..c9afecb7af4 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/src/GraphQl/Tests/Type/TypeBuilderTest.php @@ -11,9 +11,11 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type; +namespace ApiPlatform\GraphQl\Tests\Type; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\GraphQl\Tests\Fixtures\Enum\GamePlayMode; use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeBuilder; use ApiPlatform\GraphQl\Type\TypesContainerInterface; @@ -25,8 +27,6 @@ use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; diff --git a/tests/GraphQl/Type/TypeConverterTest.php b/src/GraphQl/Tests/Type/TypeConverterTest.php similarity index 98% rename from tests/GraphQl/Type/TypeConverterTest.php rename to src/GraphQl/Tests/Type/TypeConverterTest.php index 8cc24b79c14..0ad0bac50ef 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/src/GraphQl/Tests/Type/TypeConverterTest.php @@ -11,22 +11,22 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type; +namespace ApiPlatform\GraphQl\Tests\Type; -use ApiPlatform\Exception\ResourceClassNotFoundException; +use ApiPlatform\GraphQl\Tests\Fixtures\Enum\GenderTypeEnum; +use ApiPlatform\GraphQl\Tests\Fixtures\Type\Definition\DateTimeType; use ApiPlatform\GraphQl\Type\TypeBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeConverter; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; -use ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Type\Definition\DateTimeType; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; diff --git a/tests/GraphQl/Type/TypesContainerTest.php b/src/GraphQl/Tests/Type/TypesContainerTest.php similarity index 97% rename from tests/GraphQl/Type/TypesContainerTest.php rename to src/GraphQl/Tests/Type/TypesContainerTest.php index f78c9929cf5..be0bafa41e9 100644 --- a/tests/GraphQl/Type/TypesContainerTest.php +++ b/src/GraphQl/Tests/Type/TypesContainerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type; +namespace ApiPlatform\GraphQl\Tests\Type; use ApiPlatform\GraphQl\Type\TypeNotFoundException; use ApiPlatform\GraphQl\Type\TypesContainer; diff --git a/tests/GraphQl/Type/TypesFactoryTest.php b/src/GraphQl/Tests/Type/TypesFactoryTest.php similarity index 96% rename from tests/GraphQl/Type/TypesFactoryTest.php rename to src/GraphQl/Tests/Type/TypesFactoryTest.php index 06c4a9880e7..6b0a21b2169 100644 --- a/tests/GraphQl/Type/TypesFactoryTest.php +++ b/src/GraphQl/Tests/Type/TypesFactoryTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\GraphQl\Type; +namespace ApiPlatform\GraphQl\Tests\Type; use ApiPlatform\GraphQl\Type\Definition\TypeInterface; use ApiPlatform\GraphQl\Type\TypesFactory; diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 100f93d923e..acee6e139eb 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -13,7 +13,6 @@ namespace ApiPlatform\GraphQl\Type; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface; @@ -24,8 +23,9 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\State\Pagination\Pagination; -use ApiPlatform\Util\Inflector; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; @@ -82,7 +82,8 @@ public function getItemQueryFields(string $resourceClass, Operation $operation, if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $operation->getDescription(), $operation->getDeprecationReason(), new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $operation)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $operation); - $configuration['args'] = $args ?: $configuration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]]; + $extraArgs = $this->resolveResourceArgs($operation->getExtraArgs() ?? [], $operation); + $configuration['args'] = $args ?: $configuration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]] + $extraArgs; return [$fieldName => array_merge($fieldConfiguration, $configuration)]; } @@ -103,7 +104,8 @@ public function getCollectionQueryFields(string $resourceClass, Operation $opera if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $operation->getDescription(), $operation->getDeprecationReason(), new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $operation)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $operation); - $configuration['args'] = $args ?: $configuration['args'] ?? $fieldConfiguration['args']; + $extraArgs = $this->resolveResourceArgs($operation->getExtraArgs() ?? [], $operation); + $configuration['args'] = $args ?: $configuration['args'] ?? $fieldConfiguration['args'] + $extraArgs; return [Inflector::pluralize($fieldName) => array_merge($fieldConfiguration, $configuration)]; } @@ -195,7 +197,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o return $fields; } - if (!$input || 'create' !== $operation->getName()) { + if (!$input || (!$operation->getResolver() && 'create' !== $operation->getName())) { $fields['id'] = $idField; } if ($input && $depth >= 1) { @@ -211,17 +213,23 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o 'denormalization_groups' => $operation->getDenormalizationContext()['groups'] ?? null, ]; $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $context); + $propertyTypes = $propertyMetadata->getBuiltinTypes(); if ( - null === ($propertyType = $propertyMetadata->getBuiltinTypes()[0] ?? null) + !$propertyTypes || (!$input && false === $propertyMetadata->isReadable()) || ($input && $operation instanceof Mutation && false === $propertyMetadata->isWritable()) ) { continue; } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; + // guess union/intersect types: check each type until finding a valid one + foreach ($propertyTypes as $propertyType) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { + $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; + // stop at the first valid type + break; + } } } } diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index b01af9d2d12..812565d1998 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -13,9 +13,9 @@ namespace ApiPlatform\GraphQl\Type; -use ApiPlatform\Exception\OperationNotFoundException; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; @@ -147,6 +147,9 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadataCo if ($input && $operation instanceof Mutation && null !== $mutationArgs = $operation->getArgs()) { return $fieldsBuilder->resolveResourceArgs($mutationArgs, $operation) + ['clientMutationId' => $fields['clientMutationId']]; } + if ($input && $operation instanceof Mutation && null !== $extraMutationArgs = $operation->getExtraArgs()) { + return $fields + $fieldsBuilder->resolveResourceArgs($extraMutationArgs, $operation); + } return $fields; }, diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index 41c98feb1f9..390ee7c5511 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -13,9 +13,9 @@ namespace ApiPlatform\GraphQl\Type; -use ApiPlatform\Exception\InvalidArgumentException; -use ApiPlatform\Exception\OperationNotFoundException; -use ApiPlatform\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json new file mode 100644 index 00000000000..f41f36ba3ad --- /dev/null +++ b/src/GraphQl/composer.json @@ -0,0 +1,89 @@ +{ + "name": "api-platform/graphql", + "description": "Build GraphQL API endpoints", + "type": "library", + "keywords": [ + "GraphQL", + "API" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "api-platform/serializer": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", + "symfony/property-info": "^6.1", + "symfony/serializer": "^6.1", + "webonyx/graphql-php": "^14.0 || ^15.0" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "api-platform/symfony": "*@dev || ^3.1", + "api-platform/validator": "*@dev || ^3.1", + "twig/twig": "^3.7", + "symfony/mercure-bundle": "*", + "symfony/phpunit-bridge": "^6.1", + "symfony/routing": "^6.1", + "symfony/validator": "^6.1" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\GraphQl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + }, + { + "type": "path", + "url": "../State" + }, + { + "type": "path", + "url": "../Serializer" + }, + { + "type": "path", + "url": "../Symfony" + }, + { + "type": "path", + "url": "../Validator" + } + ] +} diff --git a/src/GraphQl/phpunit.xml.dist b/src/GraphQl/phpunit.xml.dist new file mode 100644 index 00000000000..0e1e002f892 --- /dev/null +++ b/src/GraphQl/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 2ce87b95fc0..d54d601e91a 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -143,31 +143,42 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($attributes as $attribute) { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - $isOne = $isMany = false; - - if (null !== $type) { - if ($type->isCollection()) { - $valueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $className = $type->getClassName(); - $isOne = $className && $this->resourceClassResolver->isResourceClass($className); + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // prevent declaring $attribute as attribute if it's already declared as relationship + $isRelationship = false; + + foreach ($types as $type) { + $isOne = $isMany = false; + + if (null !== $type) { + if ($type->isCollection()) { + $valueType = $type->getCollectionValueTypes()[0] ?? null; + $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } else { + $className = $type->getClassName(); + $isOne = $className && $this->resourceClassResolver->isResourceClass($className); + } } - } - if (!$isOne && !$isMany) { - $components['states'][] = $attribute; - continue; - } + if (!$isOne && !$isMany) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } + + $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many']; + if ($propertyMetadata->isReadableLink()) { + $components['embedded'][] = $relation; + } - $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many']; - if ($propertyMetadata->isReadableLink()) { - $components['embedded'][] = $relation; + $components['links'][] = $relation; + $isRelationship = true; } - $components['links'][] = $relation; + // if all types are not relationships, declare it as an attribute + if (!$isRelationship) { + $components['states'][] = $attribute; + } } if (false !== $context['cache_key']) { diff --git a/src/HttpCache/.gitignore b/src/HttpCache/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/HttpCache/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/HttpCache/EventListener/AddHeadersListener.php b/src/HttpCache/EventListener/AddHeadersListener.php index 5450ad072a2..a963e103a2a 100644 --- a/src/HttpCache/EventListener/AddHeadersListener.php +++ b/src/HttpCache/EventListener/AddHeadersListener.php @@ -22,6 +22,8 @@ * Configures cache HTTP headers for the current response. * * @author Kévin Dunglas + * + * @deprecated use \Symfony\EventListener\AddHeadersListener.php instead */ final class AddHeadersListener { diff --git a/src/HttpCache/EventListener/AddTagsListener.php b/src/HttpCache/EventListener/AddTagsListener.php index 557b870ed9c..92badc6128d 100644 --- a/src/HttpCache/EventListener/AddTagsListener.php +++ b/src/HttpCache/EventListener/AddTagsListener.php @@ -13,11 +13,11 @@ namespace ApiPlatform\HttpCache\EventListener; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\HttpCache\PurgerInterface; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\UriVariablesResolverTrait; use ApiPlatform\Util\OperationRequestInitiatorTrait; use ApiPlatform\Util\RequestAttributesExtractor; @@ -34,6 +34,8 @@ * @see https://docs.varnish-software.com/varnish-cache-plus/vmods/ykey/ * * @author Kévin Dunglas + * + * @deprecated use \Symfony\EventListener\AddTagsListener.php instead */ final class AddTagsListener { diff --git a/src/HttpCache/LICENSE b/src/HttpCache/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/HttpCache/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/HttpCache/README.md b/src/HttpCache/README.md new file mode 100644 index 00000000000..f5ebb6814b7 --- /dev/null +++ b/src/HttpCache/README.md @@ -0,0 +1,7 @@ +# API Platform - HTTP Cache + +HTTP Cache support. + +## Resources + + diff --git a/src/HttpCache/SurrogateKeysPurger.php b/src/HttpCache/SurrogateKeysPurger.php index 982c1c76128..0206dea9ace 100644 --- a/src/HttpCache/SurrogateKeysPurger.php +++ b/src/HttpCache/SurrogateKeysPurger.php @@ -13,7 +13,7 @@ namespace ApiPlatform\HttpCache; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\HttpClient\HttpClientInterface; diff --git a/tests/HttpCache/SouinPurgerTest.php b/src/HttpCache/Tests/SouinPurgerTest.php similarity index 99% rename from tests/HttpCache/SouinPurgerTest.php rename to src/HttpCache/Tests/SouinPurgerTest.php index ae27d3800b5..ac66be6f63b 100644 --- a/tests/HttpCache/SouinPurgerTest.php +++ b/src/HttpCache/Tests/SouinPurgerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\HttpCache; +namespace ApiPlatform\HttpCache\Tests; use ApiPlatform\HttpCache\SouinPurger; use GuzzleHttp\ClientInterface; diff --git a/tests/HttpCache/VarnishPurgerTest.php b/src/HttpCache/Tests/VarnishPurgerTest.php similarity index 95% rename from tests/HttpCache/VarnishPurgerTest.php rename to src/HttpCache/Tests/VarnishPurgerTest.php index f997d98cbb8..1217c69a95c 100644 --- a/tests/HttpCache/VarnishPurgerTest.php +++ b/src/HttpCache/Tests/VarnishPurgerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\HttpCache; +namespace ApiPlatform\HttpCache\Tests; use ApiPlatform\HttpCache\VarnishPurger; use GuzzleHttp\ClientInterface; @@ -187,4 +187,12 @@ public function testConstructor(): void $purger->purge(['/foo']); } + + public function testGetResponseHeader(): void + { + $clientProphecy = $this->prophesize(HttpClientInterface::class); + + $purger = new VarnishPurger([$clientProphecy->reveal()]); + self::assertSame(['Cache-Tags' => '/foo'], $purger->getResponseHeaders(['/foo'])); + } } diff --git a/tests/HttpCache/VarnishXKeyPurgerTest.php b/src/HttpCache/Tests/VarnishXKeyPurgerTest.php similarity index 99% rename from tests/HttpCache/VarnishXKeyPurgerTest.php rename to src/HttpCache/Tests/VarnishXKeyPurgerTest.php index 21ec88fd644..85e831698f0 100644 --- a/tests/HttpCache/VarnishXKeyPurgerTest.php +++ b/src/HttpCache/Tests/VarnishXKeyPurgerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\HttpCache; +namespace ApiPlatform\HttpCache\Tests; use ApiPlatform\HttpCache\VarnishXKeyPurger; use GuzzleHttp\ClientInterface; diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json new file mode 100644 index 00000000000..bedb11b765d --- /dev/null +++ b/src/HttpCache/composer.json @@ -0,0 +1,66 @@ +{ + "name": "api-platform/http-cache", + "description": "HttpCache support", + "type": "library", + "keywords": [ + "Cache", + "Http" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/comunnity/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "symfony/http-foundation": "^6.1" + }, + "require-dev": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "symfony/dependency-injection": "^6.1", + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1", + "symfony/http-client": "^6.1" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\HttpCache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + } + ] +} diff --git a/src/HttpCache/phpunit.xml.dist b/src/HttpCache/phpunit.xml.dist new file mode 100644 index 00000000000..d2a2213bbf7 --- /dev/null +++ b/src/HttpCache/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + + diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index dc90fc9036e..540834d92f5 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -91,14 +91,15 @@ public function buildSchema(string $className, string $format = 'jsonld', string $items = $schema['items']; unset($schema['items']); - $nullableStringDefinition = ['type' => 'string']; - switch ($schema->getVersion()) { + // JSON Schema + OpenAPI 3.1 + case Schema::VERSION_OPENAPI: case Schema::VERSION_JSON_SCHEMA: $nullableStringDefinition = ['type' => ['string', 'null']]; break; - case Schema::VERSION_OPENAPI: - $nullableStringDefinition = ['type' => 'string', 'nullable' => true]; + // Swagger + default: + $nullableStringDefinition = ['type' => 'string']; break; } diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index b40f390cd2b..c947122a6ef 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -13,11 +13,11 @@ namespace ApiPlatform\Hydra\Serializer; -use ApiPlatform\Api\FilterInterface; use ApiPlatform\Api\FilterLocatorTrait; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Serializer\CacheableSupportsMethodInterface; use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\Exception\UnexpectedValueException; diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 06fb60899e1..ef373caa2e8 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -226,7 +226,7 @@ private function getHydraOperations(bool $collection, ResourceMetadataCollection $hydraOperations = []; foreach ($resourceMetadataCollection as $resourceMetadata) { foreach ($resourceMetadata->getOperations() as $operation) { - if ((HttpOperation::METHOD_POST === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { + if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { continue; } @@ -242,7 +242,7 @@ private function getHydraOperations(bool $collection, ResourceMetadataCollection */ private function getHydraOperation(HttpOperation $operation, string $prefixedShortName): array { - $method = $operation->getMethod() ?: HttpOperation::METHOD_GET; + $method = $operation->getMethod() ?: 'GET'; $hydraOperation = $operation->getHydraContext() ?? []; if ($operation->getDeprecationReason()) { @@ -311,7 +311,7 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho /** * Gets the range of the property. */ - private function getRange(ApiProperty $propertyMetadata): ?string + private function getRange(ApiProperty $propertyMetadata): array|string|null { $jsonldContext = $propertyMetadata->getJsonldContext(); @@ -319,47 +319,69 @@ private function getRange(ApiProperty $propertyMetadata): ?string return $jsonldContext['@type']; } - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if (null === $type) { - return null; - } + $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; + $types = []; - if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) { - $type = $collectionType; - } + foreach ($builtInTypes as $type) { + if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) { + $type = $collectionType; + } - switch ($type->getBuiltinType()) { - case Type::BUILTIN_TYPE_STRING: - return 'xmls:string'; - case Type::BUILTIN_TYPE_INT: - return 'xmls:integer'; - case Type::BUILTIN_TYPE_FLOAT: - return 'xmls:decimal'; - case Type::BUILTIN_TYPE_BOOL: - return 'xmls:boolean'; - case Type::BUILTIN_TYPE_OBJECT: - if (null === $className = $type->getClassName()) { - return null; - } + switch ($type->getBuiltinType()) { + case Type::BUILTIN_TYPE_STRING: + if (!\in_array('xmls:string', $types, true)) { + $types[] = 'xmls:string'; + } + break; + case Type::BUILTIN_TYPE_INT: + if (!\in_array('xmls:integer', $types, true)) { + $types[] = 'xmls:integer'; + } + break; + case Type::BUILTIN_TYPE_FLOAT: + if (!\in_array('xmls:decimal', $types, true)) { + $types[] = 'xmls:decimal'; + } + break; + case Type::BUILTIN_TYPE_BOOL: + if (!\in_array('xmls:boolean', $types, true)) { + $types[] = 'xmls:boolean'; + } + break; + case Type::BUILTIN_TYPE_OBJECT: + if (null === $className = $type->getClassName()) { + continue 2; + } - if (is_a($className, \DateTimeInterface::class, true)) { - return 'xmls:dateTime'; - } + if (is_a($className, \DateTimeInterface::class, true)) { + if (!\in_array('xmls:dateTime', $types, true)) { + $types[] = 'xmls:dateTime'; + } + break; + } + + if ($this->resourceClassResolver->isResourceClass($className)) { + $resourceMetadata = $this->resourceMetadataFactory->create($className); + $operation = $resourceMetadata->getOperation(); - if ($this->resourceClassResolver->isResourceClass($className)) { - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + if (!$operation instanceof HttpOperation || !$operation->getTypes()) { + if (!\in_array("#{$operation->getShortName()}", $types, true)) { + $types[] = "#{$operation->getShortName()}"; + } + break; + } - if (!$operation instanceof HttpOperation) { - return "#{$operation->getShortName()}"; + $types = array_unique(array_merge($types, $operation->getTypes())); + break; } + } + } - return $operation->getTypes()[0] ?? "#{$operation->getShortName()}"; - } + if ([] === $types) { + return null; } - return null; + return 1 === \count($types) ? $types[0] : $types; } /** @@ -464,13 +486,6 @@ private function getProperty(ApiProperty $propertyMetadata, string $propertyName 'domain' => $prefixedShortName, ]; - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - - if (null !== $type && !$type->isCollection() && (null !== $className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $propertyData['owl:maxCardinality'] = 1; - } - $property = [ '@type' => 'hydra:SupportedProperty', 'hydra:property' => $propertyData, @@ -488,7 +503,7 @@ private function getProperty(ApiProperty $propertyMetadata, string $propertyName $property['hydra:description'] = $description; } - if ($deprecationReason = $propertyMetadata->getDeprecationReason()) { + if ($propertyMetadata->getDeprecationReason()) { $property['owl:deprecated'] = true; } diff --git a/src/Hydra/Serializer/ErrorNormalizer.php b/src/Hydra/Serializer/ErrorNormalizer.php index d31c4a5d7b4..d51b3d1495e 100644 --- a/src/Hydra/Serializer/ErrorNormalizer.php +++ b/src/Hydra/Serializer/ErrorNormalizer.php @@ -44,6 +44,7 @@ public function __construct(private readonly UrlGeneratorInterface $urlGenerator */ public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__)); $data = [ '@context' => $this->urlGenerator->generate('api_jsonld_context', ['shortName' => 'Error']), '@type' => 'hydra:Error', @@ -63,6 +64,10 @@ public function normalize(mixed $object, string $format = null, array $context = */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { + if ($context['skip_deprecated_exception_normalizers'] ?? false) { + return false; + } + return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); } @@ -70,8 +75,8 @@ public function getSupportedTypes($format): array { if (self::FORMAT === $format) { return [ - \Exception::class => true, - FlattenException::class => true, + \Exception::class => false, + FlattenException::class => false, ]; } @@ -89,6 +94,6 @@ public function hasCacheableSupportsMethod(): bool ); } - return true; + return false; } } diff --git a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php index 148119dd57c..85be505017a 100644 --- a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php +++ b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php @@ -99,9 +99,12 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + foreach ($types as $type) { + if (null !== $type->getClassName()) { + return "data/relationships/$fieldName"; + } } return "data/attributes/$fieldName"; diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 2d4b26ad7eb..32ca637faa9 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -44,6 +44,7 @@ public function __construct(private readonly bool $debug = false, array $default */ public function normalize(mixed $object, string $format = null, array $context = []): array { + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__)); $data = [ 'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE], 'description' => $this->getErrorMessage($object, $context, $this->debug), @@ -65,6 +66,10 @@ public function normalize(mixed $object, string $format = null, array $context = */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { + if ($context['skip_deprecated_exception_normalizers'] ?? false) { + return false; + } + return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); } @@ -72,8 +77,8 @@ public function getSupportedTypes($format): array { if (self::FORMAT === $format) { return [ - \Exception::class => true, - FlattenException::class => true, + \Exception::class => false, + FlattenException::class => false, ]; } @@ -91,6 +96,6 @@ public function hasCacheableSupportsMethod(): bool ); } - return true; + return false; } } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index f3e4dbd0141..40e274306e3 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -26,6 +26,7 @@ use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; +use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -62,7 +63,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { - return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context); + return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } public function getSupportedTypes($format): array @@ -284,32 +285,40 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - $isOne = $isMany = false; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // prevent declaring $attribute as attribute if it's already declared as relationship + $isRelationship = false; + + foreach ($types as $type) { + $isOne = $isMany = false; - if (null !== $type) { if ($type->isCollection()) { $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); } else { $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className); } - } - if (!isset($className) || !$isOne && !$isMany) { - $components['attributes'][] = $attribute; + if (!isset($className) || !$isOne && !$isMany) { + // don't declare it as an attribute too quick: maybe the next type is a valid resource + continue; + } - continue; - } + $relation = [ + 'name' => $attribute, + 'type' => $this->getResourceShortName($className), + 'cardinality' => $isOne ? 'one' : 'many', + ]; - $relation = [ - 'name' => $attribute, - 'type' => $this->getResourceShortName($className), - 'cardinality' => $isOne ? 'one' : 'many', - ]; + $components['relationships'][] = $relation; + $isRelationship = true; + } - $components['relationships'][] = $relation; + // if all types are not relationships, declare it as an attribute + if (!$isRelationship) { + $components['attributes'][] = $attribute; + } } if (false !== $context['cache_key']) { diff --git a/src/JsonApi/State/DefaultErrorProvider.php b/src/JsonApi/State/DefaultErrorProvider.php new file mode 100644 index 00000000000..a441d05a718 --- /dev/null +++ b/src/JsonApi/State/DefaultErrorProvider.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\JsonApi\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface; + +/** + * @internal + */ +final class DefaultErrorProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + $exception = $context['previous_data']; + + if ($exception instanceof ConstraintViolationListAwareExceptionInterface) { + return $exception->getConstraintViolationList(); + } + + return $exception; + } +} diff --git a/src/JsonLd/Action/ContextAction.php b/src/JsonLd/Action/ContextAction.php index e224a7198a9..68527fb2ef6 100644 --- a/src/JsonLd/Action/ContextAction.php +++ b/src/JsonLd/Action/ContextAction.php @@ -46,6 +46,7 @@ public function __invoke(string $shortName): array return ['@context' => $this->contextBuilder->getEntrypointContext()]; } + // TODO: remove this, exceptions are resources since 3.2 if (isset(self::RESERVED_SHORT_NAMES[$shortName])) { return ['@context' => $this->contextBuilder->getBaseContext()]; } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index e0cc8a171ed..f52953bdc38 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -103,7 +103,7 @@ public function normalize(mixed $object, string $format = null, array $context = $context['item_uri_template'] = $itemUriTemplate; } - if ($iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) { + if (true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) { $context['iri'] = $iri; $metadata['@id'] = $iri; } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php new file mode 100644 index 00000000000..cf1c294e19e --- /dev/null +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -0,0 +1,276 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Metadata\Property\Factory; + +use ApiPlatform\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * Build ApiProperty::schema. + */ +final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + use ResourceClassInfoTrait; + + public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null) + { + $this->resourceClassResolver = $resourceClassResolver; + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + if (null === $this->decorated) { + $propertyMetadata = new ApiProperty(); + } else { + try { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + } catch (PropertyNotFoundException) { + $propertyMetadata = new ApiProperty(); + } + } + + $propertySchema = $propertyMetadata->getSchema() ?? []; + + if (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) { + $propertySchema['writeOnly'] = true; + } + + if (!\array_key_exists('description', $propertySchema) && null !== ($description = $propertyMetadata->getDescription())) { + $propertySchema['description'] = $description; + } + + // see https://github.com/json-schema-org/json-schema-spec/pull/737 + if (!\array_key_exists('deprecated', $propertySchema) && null !== $propertyMetadata->getDeprecationReason()) { + $propertySchema['deprecated'] = true; + } + + // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it + // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4 + if (!\array_key_exists('externalDocs', $propertySchema) && null !== ($iri = $propertyMetadata->getTypes()[0] ?? null)) { + $propertySchema['externalDocs'] = ['url' => $iri]; + } + + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) { + if ($default instanceof \BackedEnum) { + $default = $default->value; + } + $propertySchema['default'] = $default; + } + + if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) { + $propertySchema['example'] = $example; + } + + if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) { + $propertySchema['example'] = $propertySchema['default']; + } + + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + // never override the following keys if at least one is already set + if ([] === $types + || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + ) { + return $propertyMetadata->withSchema($propertySchema); + } + + $valueSchema = []; + foreach ($types as $type) { + if ($isCollection = $type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $valueType = $type->getCollectionValueTypes()[0] ?? null; + } else { + $keyType = null; + $valueType = $type; + } + + if (null === $valueType) { + $builtinType = 'string'; + $className = null; + } else { + $builtinType = $valueType->getBuiltinType(); + $className = $valueType->getClassName(); + } + + if (!\array_key_exists('owl:maxCardinality', $propertySchema) + && !$isCollection + && null !== $className + && $this->resourceClassResolver->isResourceClass($className) + ) { + $propertySchema['owl:maxCardinality'] = 1; + } + + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); + if (!\in_array($propertyType, $valueSchema, true)) { + $valueSchema[] = $propertyType; + } + } + + // only one builtInType detected (should be "type" or "$ref") + if (1 === \count($valueSchema)) { + return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]); + } + + // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types + try { + $reflectionClass = new \ReflectionClass($resourceClass); + $reflectionProperty = $reflectionClass->getProperty($property); + $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf'; + } catch (\ReflectionException) { + // cannot detect types + $composition = 'anyOf'; + } + + return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]); + } + + private function getType(Type $type, bool $readableLink = null): array + { + if (!$type->isCollection()) { + return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type); + } + + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType, $readableLink), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getType($subType, $readableLink), + ], $type); + } + + private function typeToArray(Type $type, bool $readableLink = null): array + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_INT => ['type' => 'integer'], + Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink), + default => ['type' => 'string'], + }; + } + + /** + * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + * + * Note: if the class is not part of exceptions listed above, any class is considered as a resource. + */ + private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return [ + 'type' => 'string', + 'format' => 'date-time', + ]; + } + + if (is_a($className, \DateInterval::class, true)) { + return [ + 'type' => 'string', + 'format' => 'duration', + ]; + } + + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'uuid', + ]; + } + + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } + + if (is_a($className, \SplFileInfo::class, true)) { + return [ + 'type' => 'string', + 'format' => 'binary', + ]; + } + + if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'int'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + if (true !== $readableLink && $this->isResourceClass($className)) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + ]; + } + + return ['type' => 'string']; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + { + if (!$type->isNullable()) { + return $jsonSchema; + } + + if (\array_key_exists('$ref', $jsonSchema)) { + return ['anyOf' => [$jsonSchema, 'type' => 'null']]; + } + + return [...$jsonSchema, ...[ + 'type' => \is_array($jsonSchema['type']) + ? array_merge($jsonSchema['type'], ['null']) + : [$jsonSchema['type'], 'null'], + ]]; + } +} diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 00188d1f431..521880c7329 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -24,7 +24,6 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -42,8 +41,12 @@ final class SchemaFactory implements SchemaFactoryInterface public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(private readonly TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) { + if ($typeFactory) { + trigger_deprecation('api-platform/core', '3.2', sprintf('The "%s" is not needed anymore and will not be used anymore.', TypeFactoryInterface::class)); + } + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; } @@ -144,7 +147,6 @@ public function buildSchema(string $className, string $format = 'json', string $ private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format): void { $version = $schema->getVersion(); - $swagger = Schema::VERSION_SWAGGER === $version; if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); } else { @@ -156,74 +158,49 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $additionalPropertySchema ?? [] ); - if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { - $propertySchema['readOnly'] = true; - } - if (!$swagger && false === $propertyMetadata->isReadable()) { - $propertySchema['writeOnly'] = true; - } - if (null !== $description = $propertyMetadata->getDescription()) { - $propertySchema['description'] = $description; - } - - $deprecationReason = $propertyMetadata->getDeprecationReason(); - - // see https://github.com/json-schema-org/json-schema-spec/pull/737 - if (!$swagger && null !== $deprecationReason) { - $propertySchema['deprecated'] = true; - } - // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it - // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4 - $iri = $propertyMetadata->getTypes()[0] ?? null; - if (null !== $iri) { - $propertySchema['externalDocs'] = ['url' => $iri]; - } + $types = $propertyMetadata->getBuiltinTypes() ?? []; - // TODO: 3.0 support multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + // never override the following keys if at least one is already set + // or if property has no type(s) defined + // or if property schema is already fully defined (type=string + format || enum) + $propertySchemaType = $propertySchema['type'] ?? false; + if ([] === $types + || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) + || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + ) { + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) { - if ($default instanceof \BackedEnum) { - $default = $default->value; - } - $propertySchema['default'] = $default; + return; } - if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; - } + // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) + // complete property schema with resource reference ($ref) only if it's related to an object - if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { - $propertySchema['example'] = $propertySchema['default']; - } + $version = $schema->getVersion(); + $subSchema = new Schema($version); + $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - $valueSchema = []; - if (null !== $type) { - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; + foreach ($types as $type) { + if ($type->isCollection()) { $valueType = $type->getCollectionValueTypes()[0] ?? null; } else { - $keyType = null; $valueType = $type; } - if (null === $valueType) { - $builtinType = 'string'; - $className = null; - } else { - $builtinType = $valueType->getBuiltinType(); - $className = $valueType->getClassName(); + $className = $valueType?->getClassName(); + if (null === $className || !$this->isResourceClass($className)) { + continue; } - $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema); + $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; + // prevent "type" and "anyOf" conflict + unset($propertySchema['type']); + break; } - if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) { - $propertySchema = new \ArrayObject($propertySchema); - } else { - $propertySchema = new \ArrayObject($propertySchema + $valueSchema); - } - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema; + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); } private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string diff --git a/tests/Fixtures/DummyResourceImplementation.php b/src/JsonSchema/Tests/Fixtures/DummyResourceImplementation.php similarity index 90% rename from tests/Fixtures/DummyResourceImplementation.php rename to src/JsonSchema/Tests/Fixtures/DummyResourceImplementation.php index e96d7237acb..79fa0c3a016 100644 --- a/tests/Fixtures/DummyResourceImplementation.php +++ b/src/JsonSchema/Tests/Fixtures/DummyResourceImplementation.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures; +namespace ApiPlatform\JsonSchema\Tests\Fixtures; class DummyResourceImplementation implements DummyResourceInterface { diff --git a/tests/Fixtures/DummyResourceInterface.php b/src/JsonSchema/Tests/Fixtures/DummyResourceInterface.php similarity index 87% rename from tests/Fixtures/DummyResourceInterface.php rename to src/JsonSchema/Tests/Fixtures/DummyResourceInterface.php index 52419b72e2e..b0601a80610 100644 --- a/tests/Fixtures/DummyResourceInterface.php +++ b/src/JsonSchema/Tests/Fixtures/DummyResourceInterface.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures; +namespace ApiPlatform\JsonSchema\Tests\Fixtures; interface DummyResourceInterface { diff --git a/src/JsonSchema/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php b/src/JsonSchema/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php new file mode 100644 index 00000000000..1af958aaaf2 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/NotAResourceWithUnionIntersectTypes.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures; + +/** + * This class is not mapped as an API resource. + * It intends to test union and intersect types. + * + * @author Vincent Chalamon + */ +class NotAResourceWithUnionIntersectTypes +{ + public function __construct( + private $ignoredProperty, + private string|int|float|null $unionType, + private Serializable&DummyResourceInterface $intersectType + ) { + } + + public function getIgnoredProperty() + { + return $this->ignoredProperty; + } + + public function getUnionType() + { + return $this->unionType; + } + + public function getIntersectType() + { + return $this->intersectType; + } +} diff --git a/src/JsonSchema/Tests/Fixtures/Serializable.php b/src/JsonSchema/Tests/Fixtures/Serializable.php new file mode 100644 index 00000000000..028ac022971 --- /dev/null +++ b/src/JsonSchema/Tests/Fixtures/Serializable.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema\Tests\Fixtures; + +interface Serializable +{ + public function __serialize(): array; + + public function __unserialize(array $data); +} diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 806db5dbd63..4d6dbf412e9 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -16,9 +16,11 @@ use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\Tests\Fixtures\ApiResource\OverriddenOperationDummy; +use ApiPlatform\JsonSchema\Tests\Fixtures\DummyResourceInterface; use ApiPlatform\JsonSchema\Tests\Fixtures\Enum\GenderTypeEnum; use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResource; -use ApiPlatform\JsonSchema\TypeFactoryInterface; +use ApiPlatform\JsonSchema\Tests\Fixtures\NotAResourceWithUnionIntersectTypes; +use ApiPlatform\JsonSchema\Tests\Fixtures\Serializable; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Operations; @@ -41,40 +43,38 @@ class SchemaFactoryTest extends TestCase public function testBuildSchemaForNonResourceClass(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING) - ), Argument::cetera())->willReturn([ - 'type' => 'string', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_INT) - ), Argument::cetera())->willReturn([ - 'type' => 'integer', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - ]); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withReadable(true)->withDefault('default_bar')->withExample('example_bar')); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withReadable(true) + ->withDefault('default_bar') + ->withExample('example_bar') + ->withSchema(['type' => 'integer', 'default' => 'default_bar', 'example' => 'example_bar']) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)]) + ->withReadable(true) + ->withDefault('male') + ->withSchema(['type' => 'object', 'default' => 'male', 'example' => 'male']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -109,22 +109,71 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); } - public function testBuildSchemaWithSerializerGroups(): void + public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING) - ), Argument::cetera())->willReturn([ - 'type' => 'string', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - ]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, Argument::cetera())->willReturn(new PropertyNameCollection(['ignoredProperty', 'unionType', 'intersectType'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'ignoredProperty', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true)]) + ->withReadable(true) + ->withSchema(['type' => ['string', 'null']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, nullable: true), new Type(Type::BUILTIN_TYPE_INT, nullable: true), new Type(Type::BUILTIN_TYPE_FLOAT, nullable: true)]) + ->withReadable(true) + ->withSchema(['oneOf' => [ + ['type' => ['string', 'null']], + ['type' => ['integer', 'null']], + ]]) + ); + $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, class: Serializable::class), new Type(Type::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)]) + ->withReadable(true) + ->withSchema(['type' => 'object']) + ); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); + + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); + + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $definitions = $resultSchema->getDefinitions(); + + $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); + // @noRector + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); + $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); + $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); + $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); + + $this->assertArrayHasKey('ignoredProperty', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['ignoredProperty']); + $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['ignoredProperty']['type']); + + $this->assertArrayHasKey('unionType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('oneOf', $definitions[$rootDefinitionKey]['properties']['unionType']); + $this->assertCount(2, $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]); + $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]['type']); + $this->assertSame(['integer', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][1]['type']); + + $this->assertArrayHasKey('intersectType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['intersectType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); + } + + public function testBuildSchemaWithSerializerGroups(): void + { $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $operation = (new Put())->withName('put')->withNormalizationContext([ @@ -144,15 +193,31 @@ public function testBuildSchemaWithSerializerGroups(): void $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withReadable(true) + ->withSchema(['type' => 'string']) + ); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]) + ->withReadable(true) + ->withDefault(GenderTypeEnum::MALE) + ->withSchema(['type' => 'object']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); @@ -180,46 +245,29 @@ public function testBuildSchemaWithSerializerGroups(): void public function testBuildSchemaForAssociativeArray(): void { - $typeFactoryProphecy = $this->prophesize(TypeFactoryInterface::class); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING), - Argument::which('isCollection', true), - Argument::that(function (Type $type): bool { - $keyTypes = $type->getCollectionKeyTypes(); - - return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_INT === $keyTypes[0]->getBuiltinType(); - }) - ), Argument::cetera())->willReturn([ - 'type' => 'array', - ]); - $typeFactoryProphecy->getType(Argument::allOf( - Argument::type(Type::class), - Argument::which('getBuiltinType', Type::BUILTIN_TYPE_STRING), - Argument::which('isCollection', true), - Argument::that(function (Type $type): bool { - $keyTypes = $type->getCollectionKeyTypes(); - - return 1 === \count($keyTypes) && $keyTypes[0] instanceof Type && Type::BUILTIN_TYPE_STRING === $keyTypes[0]->getBuiltinType(); - }) - ), Argument::cetera())->willReturn([ - 'type' => 'object', - 'additionalProperties' => Type::BUILTIN_TYPE_STRING, - ]); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true)); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))])->withReadable(true)); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) + ); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))]) + ->withReadable(true) + ->withSchema(['type' => 'object', 'additionalProperties' => 'string']) + ); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(NotAResource::class); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index b0b65a04336..ee8d7a3f8cc 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -29,22 +29,12 @@ * @author Antoine Bluchet */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] -class ApiResource +class ApiResource extends Metadata { use WithResourceTrait; protected ?Operations $operations; - /** - * @var string|callable|null - */ - protected $provider; - - /** - * @var string|callable|null - */ - protected $processor; - /** * @param array|array|Operations|null $operations Operations is a list of HttpOperation * @param array|array|string[]|string|null $uriVariables @@ -941,6 +931,47 @@ public function __construct( protected ?OptionsInterface $stateOptions = null, protected array $extraProperties = [], ) { + parent::__construct( + shortName: $shortName, + class: $class, + description: $description, + urlGenerationStrategy: $urlGenerationStrategy, + deprecationReason: $deprecationReason, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + validationContext: $validationContext, + filters: $filters, + elasticsearch: $elasticsearch, + mercure: $mercure, + messenger: $messenger, + input: $input, + output: $output, + order: $order, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + extraProperties: $extraProperties + ); + $this->operations = null === $operations ? null : new Operations($operations); $this->provider = $provider; $this->processor = $processor; @@ -975,32 +1006,6 @@ public function withUriTemplate(string $uriTemplate): self return $self; } - public function getShortName(): ?string - { - return $this->shortName; - } - - public function withShortName(string $shortName): self - { - $self = clone $this; - $self->shortName = $shortName; - - return $self; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function withDescription(string $description): self - { - $self = clone $this; - $self->description = $description; - - return $self; - } - public function getTypes(): ?array { return $this->types; @@ -1246,45 +1251,6 @@ public function withController(string $controller): self return $self; } - public function getClass(): ?string - { - return $this->class; - } - - public function withClass(string $class): self - { - $self = clone $this; - $self->class = $class; - - return $self; - } - - public function getUrlGenerationStrategy(): ?int - { - return $this->urlGenerationStrategy; - } - - public function withUrlGenerationStrategy(int $urlGenerationStrategy): self - { - $self = clone $this; - $self->urlGenerationStrategy = $urlGenerationStrategy; - - return $self; - } - - public function getDeprecationReason(): ?string - { - return $this->deprecationReason; - } - - public function withDeprecationReason(string $deprecationReason): self - { - $self = clone $this; - $self->deprecationReason = $deprecationReason; - - return $self; - } - public function getCacheHeaders(): ?array { return $this->cacheHeaders; @@ -1298,45 +1264,6 @@ public function withCacheHeaders(array $cacheHeaders): self return $self; } - public function getNormalizationContext(): ?array - { - return $this->normalizationContext; - } - - public function withNormalizationContext(array $normalizationContext): self - { - $self = clone $this; - $self->normalizationContext = $normalizationContext; - - return $self; - } - - public function getDenormalizationContext(): ?array - { - return $this->denormalizationContext; - } - - public function withDenormalizationContext(array $denormalizationContext): self - { - $self = clone $this; - $self->denormalizationContext = $denormalizationContext; - - return $self; - } - - public function getCollectDenormalizationErrors(): ?bool - { - return $this->collectDenormalizationErrors; - } - - public function withCollectDenormalizationErrors(bool $collectDenormalizationErrors = null): self - { - $self = clone $this; - $self->collectDenormalizationErrors = $collectDenormalizationErrors; - - return $self; - } - /** * @return string[]|null */ @@ -1389,187 +1316,6 @@ public function withOpenapi(bool|OpenApiOperation $openapi): self return $self; } - public function getValidationContext(): ?array - { - return $this->validationContext; - } - - public function withValidationContext(array $validationContext): self - { - $self = clone $this; - $self->validationContext = $validationContext; - - return $self; - } - - /** - * @return string[]|null - */ - public function getFilters(): ?array - { - return $this->filters; - } - - public function withFilters(array $filters): self - { - $self = clone $this; - $self->filters = $filters; - - return $self; - } - - /** - * @deprecated this will be removed in v4 - */ - public function getElasticsearch(): ?bool - { - return $this->elasticsearch; - } - - /** - * @deprecated this will be removed in v4 - */ - public function withElasticsearch(bool $elasticsearch): self - { - $self = clone $this; - $self->elasticsearch = $elasticsearch; - - return $self; - } - - /** - * @return array|bool|mixed|null - */ - public function getMercure() - { - return $this->mercure; - } - - public function withMercure($mercure): self - { - $self = clone $this; - $self->mercure = $mercure; - - return $self; - } - - public function getMessenger() - { - return $this->messenger; - } - - public function withMessenger($messenger): self - { - $self = clone $this; - $self->messenger = $messenger; - - return $self; - } - - public function getInput() - { - return $this->input; - } - - public function withInput($input): self - { - $self = clone $this; - $self->input = $input; - - return $self; - } - - public function getOutput() - { - return $this->output; - } - - public function withOutput($output): self - { - $self = clone $this; - $self->output = $output; - - return $self; - } - - public function getOrder(): ?array - { - return $this->order; - } - - public function withOrder(array $order): self - { - $self = clone $this; - $self->order = $order; - - return $self; - } - - public function getFetchPartial(): ?bool - { - return $this->fetchPartial; - } - - public function withFetchPartial(bool $fetchPartial): self - { - $self = clone $this; - $self->fetchPartial = $fetchPartial; - - return $self; - } - - public function getForceEager(): ?bool - { - return $this->forceEager; - } - - public function withForceEager(bool $forceEager): self - { - $self = clone $this; - $self->forceEager = $forceEager; - - return $self; - } - - public function getPaginationClientEnabled(): ?bool - { - return $this->paginationClientEnabled; - } - - public function withPaginationClientEnabled(bool $paginationClientEnabled): self - { - $self = clone $this; - $self->paginationClientEnabled = $paginationClientEnabled; - - return $self; - } - - public function getPaginationClientItemsPerPage(): ?bool - { - return $this->paginationClientItemsPerPage; - } - - public function withPaginationClientItemsPerPage(bool $paginationClientItemsPerPage): self - { - $self = clone $this; - $self->paginationClientItemsPerPage = $paginationClientItemsPerPage; - - return $self; - } - - public function getPaginationClientPartial(): ?bool - { - return $this->paginationClientPartial; - } - - public function withPaginationClientPartial(bool $paginationClientPartial): self - { - $self = clone $this; - $self->paginationClientPartial = $paginationClientPartial; - - return $self; - } - public function getPaginationViaCursor(): ?array { return $this->paginationViaCursor; @@ -1583,175 +1329,6 @@ public function withPaginationViaCursor(array $paginationViaCursor): self return $self; } - public function getPaginationEnabled(): ?bool - { - return $this->paginationEnabled; - } - - public function withPaginationEnabled(bool $paginationEnabled): self - { - $self = clone $this; - $self->paginationEnabled = $paginationEnabled; - - return $self; - } - - public function getPaginationFetchJoinCollection(): ?bool - { - return $this->paginationFetchJoinCollection; - } - - public function withPaginationFetchJoinCollection(bool $paginationFetchJoinCollection): self - { - $self = clone $this; - $self->paginationFetchJoinCollection = $paginationFetchJoinCollection; - - return $self; - } - - public function getPaginationUseOutputWalkers(): ?bool - { - return $this->paginationUseOutputWalkers; - } - - public function withPaginationUseOutputWalkers(bool $paginationUseOutputWalkers): self - { - $self = clone $this; - $self->paginationUseOutputWalkers = $paginationUseOutputWalkers; - - return $self; - } - - public function getPaginationItemsPerPage(): ?int - { - return $this->paginationItemsPerPage; - } - - public function withPaginationItemsPerPage(int $paginationItemsPerPage): self - { - $self = clone $this; - $self->paginationItemsPerPage = $paginationItemsPerPage; - - return $self; - } - - public function getPaginationMaximumItemsPerPage(): ?int - { - return $this->paginationMaximumItemsPerPage; - } - - public function withPaginationMaximumItemsPerPage(int $paginationMaximumItemsPerPage): self - { - $self = clone $this; - $self->paginationMaximumItemsPerPage = $paginationMaximumItemsPerPage; - - return $self; - } - - public function getPaginationPartial(): ?bool - { - return $this->paginationPartial; - } - - public function withPaginationPartial(bool $paginationPartial): self - { - $self = clone $this; - $self->paginationPartial = $paginationPartial; - - return $self; - } - - public function getPaginationType(): ?string - { - return $this->paginationType; - } - - public function withPaginationType(string $paginationType): self - { - $self = clone $this; - $self->paginationType = $paginationType; - - return $self; - } - - public function getSecurity(): ?string - { - return $this->security; - } - - public function withSecurity(string $security): self - { - $self = clone $this; - $self->security = $security; - - return $self; - } - - public function getSecurityMessage(): ?string - { - return $this->securityMessage; - } - - public function withSecurityMessage(string $securityMessage): self - { - $self = clone $this; - $self->securityMessage = $securityMessage; - - return $self; - } - - public function getSecurityPostDenormalize(): ?string - { - return $this->securityPostDenormalize; - } - - public function withSecurityPostDenormalize(string $securityPostDenormalize): self - { - $self = clone $this; - $self->securityPostDenormalize = $securityPostDenormalize; - - return $self; - } - - public function getSecurityPostDenormalizeMessage(): ?string - { - return $this->securityPostDenormalizeMessage; - } - - public function withSecurityPostDenormalizeMessage(string $securityPostDenormalizeMessage): self - { - $self = clone $this; - $self->securityPostDenormalizeMessage = $securityPostDenormalizeMessage; - - return $self; - } - - public function getSecurityPostValidation(): ?string - { - return $this->securityPostValidation; - } - - public function withSecurityPostValidation(string $securityPostValidation = null): self - { - $self = clone $this; - $self->securityPostValidation = $securityPostValidation; - - return $self; - } - - public function getSecurityPostValidationMessage(): ?string - { - return $this->securityPostValidationMessage; - } - - public function withSecurityPostValidationMessage(string $securityPostValidationMessage = null): self - { - $self = clone $this; - $self->securityPostValidationMessage = $securityPostValidationMessage; - - return $self; - } - public function getExceptionToStatus(): ?array { return $this->exceptionToStatus; @@ -1793,62 +1370,4 @@ public function withGraphQlOperations(array $graphQlOperations): self return $self; } - - /** - * @return string|callable|null - */ - public function getProcessor() - { - return $this->processor; - } - - public function withProcessor($processor): self - { - $self = clone $this; - $self->processor = $processor; - - return $self; - } - - /** - * @return string|callable|null - */ - public function getProvider() - { - return $this->provider; - } - - public function withProvider($provider): self - { - $self = clone $this; - $self->provider = $provider; - - return $self; - } - - public function getExtraProperties(): array - { - return $this->extraProperties; - } - - public function withExtraProperties(array $extraProperties): self - { - $self = clone $this; - $self->extraProperties = $extraProperties; - - return $self; - } - - public function getStateOptions(): ?OptionsInterface - { - return $this->stateOptions; - } - - public function withStateOptions(?OptionsInterface $stateOptions): self - { - $self = clone $this; - $self->stateOptions = $stateOptions; - - return $self; - } } diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 3a53425b719..01d04eb7500 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -94,7 +94,7 @@ public function __construct( array $extraProperties = [], ) { parent::__construct( - method: self::METHOD_DELETE, + method: 'DELETE', uriTemplate: $uriTemplate, types: $types, formats: $formats, diff --git a/src/Metadata/ErrorResource.php b/src/Metadata/ErrorResource.php new file mode 100644 index 00000000000..996596903a9 --- /dev/null +++ b/src/Metadata/ErrorResource.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\State\OptionsInterface; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +class ErrorResource extends ApiResource +{ + public function __construct( + string $uriTemplate = null, + string $shortName = null, + string $description = null, + array|string $types = null, + $operations = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + string $routePrefix = null, + array $defaults = null, + array $requirements = null, + array $options = null, + bool $stateless = null, + string $sunset = null, + string $acceptPatch = null, + int $status = null, + string $host = null, + array $schemes = null, + string $condition = null, + string $controller = null, + string $class = null, + int $urlGenerationStrategy = null, + string $deprecationReason = null, + array $cacheHeaders = null, + array $normalizationContext = null, + array $denormalizationContext = null, + bool $collectDenormalizationErrors = null, + array $hydraContext = null, + array $openapiContext = null, + OpenApiOperation|bool $openapi = null, + array $validationContext = null, + array $filters = null, + bool $elasticsearch = null, + $mercure = null, + $messenger = null, + $input = null, + $output = null, + array $order = null, + bool $fetchPartial = null, + bool $forceEager = null, + bool $paginationClientEnabled = null, + bool $paginationClientItemsPerPage = null, + bool $paginationClientPartial = null, + array $paginationViaCursor = null, + bool $paginationEnabled = null, + bool $paginationFetchJoinCollection = null, + bool $paginationUseOutputWalkers = null, + int $paginationItemsPerPage = null, + int $paginationMaximumItemsPerPage = null, + bool $paginationPartial = null, + string $paginationType = null, + string $security = null, + string $securityMessage = null, + string $securityPostDenormalize = null, + string $securityPostDenormalizeMessage = null, + string $securityPostValidation = null, + string $securityPostValidationMessage = null, + bool $compositeIdentifier = null, + array $exceptionToStatus = null, + bool $queryParameterValidationEnabled = null, + array $graphQlOperations = null, + $provider = null, + $processor = null, + OptionsInterface $stateOptions = null, + array $extraProperties = [] + ) { + parent::__construct( + uriTemplate: $uriTemplate, + shortName: $shortName, + description: $description, + types: $types, + operations: $operations ?? [new Get()], + formats: $formats, + inputFormats: $inputFormats, + outputFormats: $outputFormats, + uriVariables: $uriVariables, + routePrefix: $routePrefix, + defaults: $defaults, + requirements: $requirements, + options: $options, + stateless: $stateless, + sunset: $sunset, + acceptPatch: $acceptPatch, + status: $status, + host: $host, + schemes: $schemes, + condition: $condition, + controller: $controller, + class: $class, + urlGenerationStrategy: $urlGenerationStrategy, + deprecationReason: $deprecationReason, + cacheHeaders: $cacheHeaders, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + hydraContext: $hydraContext, + openapiContext: $openapiContext, + openapi: $openapi, + validationContext: $validationContext, + filters: $filters, + elasticsearch: $elasticsearch, + mercure: $mercure, + messenger: $messenger, + input: $input, + output: $output, + order: $order, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationViaCursor: $paginationViaCursor, + paginationEnabled: $paginationEnabled, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationType: $paginationType, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + compositeIdentifier: $compositeIdentifier, + exceptionToStatus: $exceptionToStatus, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + graphQlOperations: $graphQlOperations, + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + extraProperties: $extraProperties + ); + } +} diff --git a/src/Metadata/Exception/HttpExceptionInterface.php b/src/Metadata/Exception/HttpExceptionInterface.php new file mode 100644 index 00000000000..024e23c7e2a --- /dev/null +++ b/src/Metadata/Exception/HttpExceptionInterface.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\Metadata\Exception; + +interface HttpExceptionInterface extends \Throwable +{ + /** + * Returns the status code. + */ + public function getStatusCode(): int; + + /** + * Returns response headers. + */ + public function getHeaders(): array; +} diff --git a/src/Metadata/Exception/InvalidIdentifierException.php b/src/Metadata/Exception/InvalidIdentifierException.php new file mode 100644 index 00000000000..1a23925f29f --- /dev/null +++ b/src/Metadata/Exception/InvalidIdentifierException.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\Metadata\Exception; + +use ApiPlatform\Exception\InvalidIdentifierException as LegacyInvalidIdentifierException; + +if (class_exists(LegacyInvalidIdentifierException::class)) { + class InvalidIdentifierException extends LegacyInvalidIdentifierException + { + } +} else { + /** + * Identifier is not valid exception. + * + * @author Antoine Bluchet + */ + final class InvalidIdentifierException extends \Exception implements ExceptionInterface + { + } +} diff --git a/src/Metadata/Exception/InvalidUriVariableException.php b/src/Metadata/Exception/InvalidUriVariableException.php new file mode 100644 index 00000000000..17bfede8a0e --- /dev/null +++ b/src/Metadata/Exception/InvalidUriVariableException.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\Metadata\Exception; + +use ApiPlatform\Exception\InvalidUriVariableException as LegacyInvalidUriVariableException; + +if (class_exists(LegacyInvalidUriVariableException::class)) { + class InvalidUriVariableException extends LegacyInvalidUriVariableException + { + } +} else { + /** + * Identifier is not valid exception. + * + * @author Antoine Bluchet + */ + final class InvalidUriVariableException extends \Exception implements ExceptionInterface + { + } +} diff --git a/src/Metadata/Exception/ItemNotFoundException.php b/src/Metadata/Exception/ItemNotFoundException.php new file mode 100644 index 00000000000..368664719f4 --- /dev/null +++ b/src/Metadata/Exception/ItemNotFoundException.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\Metadata\Exception; + +use ApiPlatform\Exception\ItemNotFoundException as LegacyItemNotFoundException; + +if (class_exists(LegacyItemNotFoundException::class)) { + class ItemNotFoundException extends LegacyItemNotFoundException + { + } +} else { + /** + * Item not found exception. + * + * @author Amrouche Hamza + */ + class ItemNotFoundException extends InvalidArgumentException + { + } +} diff --git a/src/Metadata/Exception/ProblemExceptionInterface.php b/src/Metadata/Exception/ProblemExceptionInterface.php new file mode 100644 index 00000000000..6b4ef927ac3 --- /dev/null +++ b/src/Metadata/Exception/ProblemExceptionInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Exception; + +/** + * Implements the Problem Error specification. + */ +interface ProblemExceptionInterface +{ + public function getType(): string; + + /** + * Note from RFC rfc7807: "title" (string) - A short, human-readable summary of the problem type. + * It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. + */ + public function getTitle(): ?string; + + public function getStatus(): ?int; + + public function getDetail(): ?string; + + public function getInstance(): ?string; +} diff --git a/src/Metadata/Extractor/ResourceExtractorTrait.php b/src/Metadata/Extractor/ResourceExtractorTrait.php index 41f311b7ae4..dc235448571 100644 --- a/src/Metadata/Extractor/ResourceExtractorTrait.php +++ b/src/Metadata/Extractor/ResourceExtractorTrait.php @@ -85,6 +85,20 @@ private function buildArgs(\SimpleXMLElement $resource): ?array return $data; } + private function buildExtraArgs(\SimpleXMLElement $resource): ?array + { + if (!isset($resource->extraArgs->arg)) { + return null; + } + + $data = []; + foreach ($resource->extraArgs->arg as $arg) { + $data[(string) $arg['id']] = $this->buildValues($arg->values); + } + + return $data; + } + private function buildValues(\SimpleXMLElement $resource): array { $data = []; diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 0ced1728101..fcd2cdb5459 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -424,6 +424,7 @@ private function buildGraphQlOperations(\SimpleXMLElement $resource, array $root $data[] = array_merge($datum, [ 'resolver' => $this->phpize($operation, 'resolver', 'string'), 'args' => $this->buildArgs($operation), + 'extraArgs' => $this->buildExtraArgs($operation), 'class' => (string) $operation['class'], 'read' => $this->phpize($operation, 'read', 'bool'), 'deserialize' => $this->phpize($operation, 'deserialize', 'bool'), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 8983cfe11c9..d1a6f1159f8 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -377,6 +377,7 @@ private function buildGraphQlOperations(array $resource, array $root): ?array $data[] = array_merge($datum, [ 'resolver' => $this->phpize($operation, 'resolver', 'string'), 'args' => $operation['args'] ?? null, + 'extraArgs' => $operation['extraArgs'] ?? null, 'class' => (string) $class, 'read' => $this->phpize($operation, 'read', 'bool'), 'deserialize' => $this->phpize($operation, 'deserialize', 'bool'), diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index e13b978e40f..2f960f49c6d 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -59,6 +59,7 @@ + diff --git a/src/Metadata/GraphQl/Operation.php b/src/Metadata/GraphQl/Operation.php index 3230d1a7da0..0833b84bfc2 100644 --- a/src/Metadata/GraphQl/Operation.php +++ b/src/Metadata/GraphQl/Operation.php @@ -37,6 +37,7 @@ class Operation extends AbstractOperation public function __construct( protected ?string $resolver = null, protected ?array $args = null, + protected ?array $extraArgs = null, protected ?array $links = null, string $shortName = null, @@ -160,6 +161,19 @@ public function withArgs(array $args = null): self return $self; } + public function getExtraArgs(): ?array + { + return $this->extraArgs; + } + + public function withExtraArgs(array $extraArgs = null): self + { + $self = clone $this; + $self->extraArgs = $extraArgs; + + return $self; + } + /** * @return Link[]|null */ diff --git a/src/Metadata/GraphQl/Query.php b/src/Metadata/GraphQl/Query.php index d5d2d4736fd..5f3546f8873 100644 --- a/src/Metadata/GraphQl/Query.php +++ b/src/Metadata/GraphQl/Query.php @@ -21,6 +21,7 @@ class Query extends Operation public function __construct( string $resolver = null, array $args = null, + array $extraArgs = null, array $links = null, string $shortName = null, @@ -74,6 +75,7 @@ public function __construct( parent::__construct( resolver: $resolver, args: $args, + extraArgs: $extraArgs, links: $links, shortName: $shortName, class: $class, diff --git a/src/Metadata/GraphQl/QueryCollection.php b/src/Metadata/GraphQl/QueryCollection.php index 0774773c5f7..bf8a962f275 100644 --- a/src/Metadata/GraphQl/QueryCollection.php +++ b/src/Metadata/GraphQl/QueryCollection.php @@ -22,6 +22,7 @@ final class QueryCollection extends Query implements CollectionOperationInterfac public function __construct( string $resolver = null, array $args = null, + array $extraArgs = null, array $links = null, string $shortName = null, @@ -75,6 +76,7 @@ public function __construct( parent::__construct( resolver: $resolver, args: $args, + extraArgs: $extraArgs, links: $links, shortName: $shortName, class: $class, diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index 6f3aead2002..c58208b6448 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -21,6 +21,7 @@ final class Subscription extends Operation public function __construct( string $resolver = null, array $args = null, + array $extraArgs = null, array $links = null, string $shortName = null, @@ -72,6 +73,7 @@ public function __construct( parent::__construct( resolver: $resolver, args: $args, + extraArgs: $extraArgs, links: $links, shortName: $shortName, class: $class, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 009db7b56cc..7ccdbaa0424 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -76,7 +76,7 @@ class HttpOperation extends Operation * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} */ public function __construct( - protected string $method = self::METHOD_GET, + protected string $method = 'GET', protected ?string $uriTemplate = null, protected ?array $types = null, protected $formats = null, diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php new file mode 100644 index 00000000000..ee706d45244 --- /dev/null +++ b/src/Metadata/IdentifiersExtractor.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +/** + * {@inheritdoc} + * + * @author Antoine Bluchet + */ +final class IdentifiersExtractor implements IdentifiersExtractorInterface +{ + use ResourceClassInfoTrait; + private readonly PropertyAccessorInterface $propertyAccessor; + + /** + * @param LegacyResourceClassResolverInterface|ResourceClassResolverInterface $resourceClassResolver + */ + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, $resourceClassResolver, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, PropertyAccessorInterface $propertyAccessor = null) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceClassResolver = $resourceClassResolver; + $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + * + * TODO: 3.0 identifiers should be stringable? + */ + public function getIdentifiersFromItem(object $item, Operation $operation = null, array $context = []): array + { + if (!$this->isResourceClass($this->getObjectClass($item))) { + return ['id' => $this->propertyAccessor->getValue($item, 'id')]; + } + + if ($operation && $operation->getClass()) { + return $this->getIdentifiersFromOperation($item, $operation, $context); + } + + $resourceClass = $this->getResourceClass($item, true); + $operation ??= $this->resourceMetadataFactory->create($resourceClass)->getOperation(null, false, true); + + return $this->getIdentifiersFromOperation($item, $operation, $context); + } + + private function getIdentifiersFromOperation(object $item, Operation $operation, array $context = []): array + { + if ($operation instanceof HttpOperation) { + $links = $operation->getUriVariables(); + } elseif ($operation instanceof GraphQlOperation) { + $links = $operation->getLinks(); + } + + $identifiers = []; + foreach ($links ?? [] as $link) { + if (1 < (is_countable($link->getIdentifiers()) ? \count($link->getIdentifiers()) : 0)) { + $compositeIdentifiers = []; + foreach ($link->getIdentifiers() as $identifier) { + $compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $identifier, $link->getParameterName()); + } + + $identifiers[$link->getParameterName()] = CompositeIdentifierParser::stringify($compositeIdentifiers); + continue; + } + + $parameterName = $link->getParameterName(); + $identifiers[$parameterName] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $link->getIdentifiers()[0], $parameterName, $link->getToProperty()); + } + + return $identifiers; + } + + /** + * Gets the value of the given class property. + */ + private function getIdentifierValue(object $item, string $class, string $property, string $parameterName, string $toProperty = null): float|bool|int|string + { + if ($item instanceof $class) { + try { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, $property), $parameterName); + } catch (NoSuchPropertyException $e) { + throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e); + } + } + + if ($toProperty) { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$toProperty.$property"), $parameterName); + } + + $resourceClass = $this->getResourceClass($item, true); + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); + + $types = $propertyMetadata->getBuiltinTypes(); + if (null === ($type = $types[0] ?? null)) { + continue; + } + + try { + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + + if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, sprintf('%s[0].%s', $propertyName, $property)), $parameterName); + } + } + + if ($type->getClassName() === $class) { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$propertyName.$property"), $parameterName); + } + } catch (NoSuchPropertyException $e) { + throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e); + } + } + + throw new RuntimeException('Not able to retrieve identifiers.'); + } + + /** + * TODO: in 3.0 this method just uses $identifierValue instanceof \Stringable and we remove the weird behavior. + * + * @param mixed|\Stringable $identifierValue + */ + private function resolveIdentifierValue(mixed $identifierValue, string $parameterName): float|bool|int|string + { + if (null === $identifierValue) { + throw new RuntimeException('No identifier value found, did you forget to persist the entity?'); + } + + if (\is_scalar($identifierValue)) { + return $identifierValue; + } + + if ($identifierValue instanceof \Stringable) { + return (string) $identifierValue; + } + + if ($identifierValue instanceof \BackedEnum) { + return (string) $identifierValue->value; + } + + throw new RuntimeException(sprintf('We were not able to resolve the identifier matching parameter "%s".', $parameterName)); + } +} diff --git a/src/Metadata/IdentifiersExtractorInterface.php b/src/Metadata/IdentifiersExtractorInterface.php new file mode 100644 index 00000000000..5ef91fde783 --- /dev/null +++ b/src/Metadata/IdentifiersExtractorInterface.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\Metadata; + +use ApiPlatform\Exception\RuntimeException; + +/** + * Extracts identifiers for a given Resource according to the retrieved Metadata. + * + * @author Antoine Bluchet + */ +interface IdentifiersExtractorInterface +{ + /** + * Finds identifiers from an Item (object). + * + * @throws RuntimeException + */ + public function getIdentifiersFromItem(object $item, Operation $operation = null, array $context = []): array; +} diff --git a/src/Metadata/IriConverterInterface.php b/src/Metadata/IriConverterInterface.php new file mode 100644 index 00000000000..b55bb75b984 --- /dev/null +++ b/src/Metadata/IriConverterInterface.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\Metadata; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; + +/** + * Converts item and resources to IRI and vice versa. + * + * @author Kévin Dunglas + */ +interface IriConverterInterface +{ + /** + * Retrieves an item from its IRI. + * + * @throws InvalidArgumentException + * @throws ItemNotFoundException + */ + public function getResourceFromIri(string $iri, array $context = [], Operation $operation = null): object; + + /** + * Gets the IRI associated with the given item. + * + * @param object|class-string $resource + * + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, Operation $operation = null, array $context = []): ?string; +} diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php new file mode 100644 index 00000000000..0b409ac8c34 --- /dev/null +++ b/src/Metadata/Metadata.php @@ -0,0 +1,581 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use ApiPlatform\State\OptionsInterface; + +/** + * @internal + */ +abstract class Metadata +{ + /** + * @param string|null $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param string|null $security https://api-platform.com/docs/core/security + * @param string|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param mixed|null $mercure + * @param mixed|null $messenger + * @param mixed|null $input + * @param mixed|null $output + * @param mixed|null $provider + * @param mixed|null $processor + */ + public function __construct( + protected ?string $shortName = null, + protected ?string $class = null, + protected ?string $description = null, + protected ?int $urlGenerationStrategy = null, + protected ?string $deprecationReason = null, + protected ?array $normalizationContext = null, + protected ?array $denormalizationContext = null, + protected ?bool $collectDenormalizationErrors = null, + protected ?array $validationContext = null, + protected ?array $filters = null, + protected ?bool $elasticsearch = null, + protected $mercure = null, + protected $messenger = null, + protected $input = null, + protected $output = null, + protected ?array $order = null, + protected ?bool $fetchPartial = null, + protected ?bool $forceEager = null, + protected ?bool $paginationEnabled = null, + protected ?string $paginationType = null, + protected ?int $paginationItemsPerPage = null, + protected ?int $paginationMaximumItemsPerPage = null, + protected ?bool $paginationPartial = null, + protected ?bool $paginationClientEnabled = null, + protected ?bool $paginationClientItemsPerPage = null, + protected ?bool $paginationClientPartial = null, + protected ?bool $paginationFetchJoinCollection = null, + protected ?bool $paginationUseOutputWalkers = null, + protected ?string $security = null, + protected ?string $securityMessage = null, + protected ?string $securityPostDenormalize = null, + protected ?string $securityPostDenormalizeMessage = null, + protected ?string $securityPostValidation = null, + protected ?string $securityPostValidationMessage = null, + protected $provider = null, + protected $processor = null, + protected ?OptionsInterface $stateOptions = null, + protected array $extraProperties = [] + ) { + } + + public function getShortName(): ?string + { + return $this->shortName; + } + + public function withShortName(string $shortName): static + { + $self = clone $this; + $self->shortName = $shortName; + + return $self; + } + + public function getClass(): ?string + { + return $this->class; + } + + public function withClass(string $class): static + { + $self = clone $this; + $self->class = $class; + + return $self; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function withDescription(string $description = null): static + { + $self = clone $this; + $self->description = $description; + + return $self; + } + + public function getUrlGenerationStrategy(): ?int + { + return $this->urlGenerationStrategy; + } + + public function withUrlGenerationStrategy(int $urlGenerationStrategy): static + { + $self = clone $this; + $self->urlGenerationStrategy = $urlGenerationStrategy; + + return $self; + } + + public function getDeprecationReason(): ?string + { + return $this->deprecationReason; + } + + public function withDeprecationReason($deprecationReason): static + { + $self = clone $this; + $self->deprecationReason = $deprecationReason; + + return $self; + } + + public function getNormalizationContext(): ?array + { + return $this->normalizationContext; + } + + public function withNormalizationContext(array $normalizationContext): static + { + $self = clone $this; + $self->normalizationContext = $normalizationContext; + + return $self; + } + + public function getDenormalizationContext(): ?array + { + return $this->denormalizationContext; + } + + public function withDenormalizationContext(array $denormalizationContext): static + { + $self = clone $this; + $self->denormalizationContext = $denormalizationContext; + + return $self; + } + + public function getCollectDenormalizationErrors(): ?bool + { + return $this->collectDenormalizationErrors; + } + + public function withCollectDenormalizationErrors(bool $collectDenormalizationErrors = null): static + { + $self = clone $this; + $self->collectDenormalizationErrors = $collectDenormalizationErrors; + + return $self; + } + + public function getValidationContext(): ?array + { + return $this->validationContext; + } + + public function withValidationContext(array $validationContext): static + { + $self = clone $this; + $self->validationContext = $validationContext; + + return $self; + } + + /** + * @return string[]|null + */ + public function getFilters(): ?array + { + return $this->filters; + } + + public function withFilters(array $filters): static + { + $self = clone $this; + $self->filters = $filters; + + return $self; + } + + /** + * @deprecated this will be removed in v4 + */ + public function getElasticsearch(): ?bool + { + return $this->elasticsearch; + } + + /** + * @deprecated this will be removed in v4 + */ + public function withElasticsearch(bool $elasticsearch): static + { + $self = clone $this; + $self->elasticsearch = $elasticsearch; + + return $self; + } + + /** + * @return array|bool|mixed|null + */ + public function getMercure() + { + return $this->mercure; + } + + public function withMercure($mercure): static + { + $self = clone $this; + $self->mercure = $mercure; + + return $self; + } + + public function getMessenger() + { + return $this->messenger; + } + + public function withMessenger($messenger): static + { + $self = clone $this; + $self->messenger = $messenger; + + return $self; + } + + public function getInput() + { + return $this->input; + } + + public function withInput($input): static + { + $self = clone $this; + $self->input = $input; + + return $self; + } + + public function getOutput() + { + return $this->output; + } + + public function withOutput($output): static + { + $self = clone $this; + $self->output = $output; + + return $self; + } + + public function getOrder(): ?array + { + return $this->order; + } + + public function withOrder(array $order): static + { + $self = clone $this; + $self->order = $order; + + return $self; + } + + public function getFetchPartial(): ?bool + { + return $this->fetchPartial; + } + + public function withFetchPartial(bool $fetchPartial): static + { + $self = clone $this; + $self->fetchPartial = $fetchPartial; + + return $self; + } + + public function getForceEager(): ?bool + { + return $this->forceEager; + } + + public function withForceEager(bool $forceEager): static + { + $self = clone $this; + $self->forceEager = $forceEager; + + return $self; + } + + public function getPaginationEnabled(): ?bool + { + return $this->paginationEnabled; + } + + public function withPaginationEnabled(bool $paginationEnabled): static + { + $self = clone $this; + $self->paginationEnabled = $paginationEnabled; + + return $self; + } + + public function getPaginationType(): ?string + { + return $this->paginationType; + } + + public function withPaginationType(string $paginationType): static + { + $self = clone $this; + $self->paginationType = $paginationType; + + return $self; + } + + public function getPaginationItemsPerPage(): ?int + { + return $this->paginationItemsPerPage; + } + + public function withPaginationItemsPerPage(int $paginationItemsPerPage): static + { + $self = clone $this; + $self->paginationItemsPerPage = $paginationItemsPerPage; + + return $self; + } + + public function getPaginationMaximumItemsPerPage(): ?int + { + return $this->paginationMaximumItemsPerPage; + } + + public function withPaginationMaximumItemsPerPage(int $paginationMaximumItemsPerPage): static + { + $self = clone $this; + $self->paginationMaximumItemsPerPage = $paginationMaximumItemsPerPage; + + return $self; + } + + public function getPaginationPartial(): ?bool + { + return $this->paginationPartial; + } + + public function withPaginationPartial(bool $paginationPartial): static + { + $self = clone $this; + $self->paginationPartial = $paginationPartial; + + return $self; + } + + public function getPaginationClientEnabled(): ?bool + { + return $this->paginationClientEnabled; + } + + public function withPaginationClientEnabled(bool $paginationClientEnabled): static + { + $self = clone $this; + $self->paginationClientEnabled = $paginationClientEnabled; + + return $self; + } + + public function getPaginationClientItemsPerPage(): ?bool + { + return $this->paginationClientItemsPerPage; + } + + public function withPaginationClientItemsPerPage(bool $paginationClientItemsPerPage): static + { + $self = clone $this; + $self->paginationClientItemsPerPage = $paginationClientItemsPerPage; + + return $self; + } + + public function getPaginationClientPartial(): ?bool + { + return $this->paginationClientPartial; + } + + public function withPaginationClientPartial(bool $paginationClientPartial): static + { + $self = clone $this; + $self->paginationClientPartial = $paginationClientPartial; + + return $self; + } + + public function getPaginationFetchJoinCollection(): ?bool + { + return $this->paginationFetchJoinCollection; + } + + public function withPaginationFetchJoinCollection(bool $paginationFetchJoinCollection): static + { + $self = clone $this; + $self->paginationFetchJoinCollection = $paginationFetchJoinCollection; + + return $self; + } + + public function getPaginationUseOutputWalkers(): ?bool + { + return $this->paginationUseOutputWalkers; + } + + public function withPaginationUseOutputWalkers(bool $paginationUseOutputWalkers): static + { + $self = clone $this; + $self->paginationUseOutputWalkers = $paginationUseOutputWalkers; + + return $self; + } + + public function getSecurity(): ?string + { + return $this->security; + } + + public function withSecurity($security): static + { + $self = clone $this; + $self->security = $security; + + return $self; + } + + public function getSecurityMessage(): ?string + { + return $this->securityMessage; + } + + public function withSecurityMessage(string $securityMessage): static + { + $self = clone $this; + $self->securityMessage = $securityMessage; + + return $self; + } + + public function getSecurityPostDenormalize(): ?string + { + return $this->securityPostDenormalize; + } + + public function withSecurityPostDenormalize($securityPostDenormalize): static + { + $self = clone $this; + $self->securityPostDenormalize = $securityPostDenormalize; + + return $self; + } + + public function getSecurityPostDenormalizeMessage(): ?string + { + return $this->securityPostDenormalizeMessage; + } + + public function withSecurityPostDenormalizeMessage(string $securityPostDenormalizeMessage): static + { + $self = clone $this; + $self->securityPostDenormalizeMessage = $securityPostDenormalizeMessage; + + return $self; + } + + public function getSecurityPostValidation(): ?string + { + return $this->securityPostValidation; + } + + public function withSecurityPostValidation(string $securityPostValidation = null): static + { + $self = clone $this; + $self->securityPostValidation = $securityPostValidation; + + return $self; + } + + public function getSecurityPostValidationMessage(): ?string + { + return $this->securityPostValidationMessage; + } + + public function withSecurityPostValidationMessage(string $securityPostValidationMessage = null): static + { + $self = clone $this; + $self->securityPostValidationMessage = $securityPostValidationMessage; + + return $self; + } + + public function getProcessor(): callable|string|null + { + return $this->processor; + } + + public function withProcessor(callable|string|null $processor): static + { + $self = clone $this; + $self->processor = $processor; + + return $self; + } + + public function getProvider(): callable|string|null + { + return $this->provider; + } + + public function withProvider(callable|string|null $provider): static + { + $self = clone $this; + $self->provider = $provider; + + return $self; + } + + public function getStateOptions(): ?OptionsInterface + { + return $this->stateOptions; + } + + public function withStateOptions(?OptionsInterface $stateOptions): static + { + $self = clone $this; + $self->stateOptions = $stateOptions; + + return $self; + } + + public function getExtraProperties(): ?array + { + return $this->extraProperties; + } + + public function withExtraProperties(array $extraProperties = []): static + { + $self = clone $this; + $self->extraProperties = $extraProperties; + + return $self; + } +} diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 672604681dc..3577fb39b41 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -30,7 +30,7 @@ final class NotExposed extends HttpOperation * {@inheritdoc} */ public function __construct( - string $method = self::METHOD_GET, + string $method = 'GET', string $uriTemplate = null, array $types = null, $formats = null, diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 844d7098d4d..c07151e4f29 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -18,7 +18,7 @@ /** * ⚠ This class and its children offer no backward compatibility regarding positional parameters. */ -abstract class Operation +abstract class Operation extends Metadata { use WithResourceTrait; @@ -773,6 +773,46 @@ public function __construct( protected ?OptionsInterface $stateOptions = null, protected array $extraProperties = [], ) { + parent::__construct( + shortName: $shortName, + class: $class, + description: $description, + urlGenerationStrategy: $urlGenerationStrategy, + deprecationReason: $deprecationReason, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + validationContext: $validationContext, + filters: $filters, + elasticsearch: $elasticsearch, + mercure: $mercure, + messenger: $messenger, + input: $input, + output: $output, + order: $order, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + extraProperties: $extraProperties, + ); } public function withOperation($operation) @@ -780,422 +820,6 @@ public function withOperation($operation) return $this->copyFrom($operation); } - public function getShortName(): ?string - { - return $this->shortName; - } - - public function withShortName(string $shortName = null): self - { - $self = clone $this; - $self->shortName = $shortName; - - return $self; - } - - public function getClass(): ?string - { - return $this->class; - } - - public function withClass(string $class = null): self - { - $self = clone $this; - $self->class = $class; - - return $self; - } - - public function getPaginationEnabled(): ?bool - { - return $this->paginationEnabled; - } - - public function withPaginationEnabled(bool $paginationEnabled = null): self - { - $self = clone $this; - $self->paginationEnabled = $paginationEnabled; - - return $self; - } - - public function getPaginationType(): ?string - { - return $this->paginationType; - } - - public function withPaginationType(string $paginationType = null): self - { - $self = clone $this; - $self->paginationType = $paginationType; - - return $self; - } - - public function getPaginationItemsPerPage(): ?int - { - return $this->paginationItemsPerPage; - } - - public function withPaginationItemsPerPage(int $paginationItemsPerPage = null): self - { - $self = clone $this; - $self->paginationItemsPerPage = $paginationItemsPerPage; - - return $self; - } - - public function getPaginationMaximumItemsPerPage(): ?int - { - return $this->paginationMaximumItemsPerPage; - } - - public function withPaginationMaximumItemsPerPage(int $paginationMaximumItemsPerPage = null): self - { - $self = clone $this; - $self->paginationMaximumItemsPerPage = $paginationMaximumItemsPerPage; - - return $self; - } - - public function getPaginationPartial(): ?bool - { - return $this->paginationPartial; - } - - public function withPaginationPartial(bool $paginationPartial = null): self - { - $self = clone $this; - $self->paginationPartial = $paginationPartial; - - return $self; - } - - public function getPaginationClientEnabled(): ?bool - { - return $this->paginationClientEnabled; - } - - public function withPaginationClientEnabled(bool $paginationClientEnabled = null): self - { - $self = clone $this; - $self->paginationClientEnabled = $paginationClientEnabled; - - return $self; - } - - public function getPaginationClientItemsPerPage(): ?bool - { - return $this->paginationClientItemsPerPage; - } - - public function withPaginationClientItemsPerPage(bool $paginationClientItemsPerPage = null): self - { - $self = clone $this; - $self->paginationClientItemsPerPage = $paginationClientItemsPerPage; - - return $self; - } - - public function getPaginationClientPartial(): ?bool - { - return $this->paginationClientPartial; - } - - public function withPaginationClientPartial(bool $paginationClientPartial = null): self - { - $self = clone $this; - $self->paginationClientPartial = $paginationClientPartial; - - return $self; - } - - public function getPaginationFetchJoinCollection(): ?bool - { - return $this->paginationFetchJoinCollection; - } - - public function withPaginationFetchJoinCollection(bool $paginationFetchJoinCollection = null): self - { - $self = clone $this; - $self->paginationFetchJoinCollection = $paginationFetchJoinCollection; - - return $self; - } - - public function getPaginationUseOutputWalkers(): ?bool - { - return $this->paginationUseOutputWalkers; - } - - public function withPaginationUseOutputWalkers(bool $paginationUseOutputWalkers = null): self - { - $self = clone $this; - $self->paginationUseOutputWalkers = $paginationUseOutputWalkers; - - return $self; - } - - public function getOrder(): ?array - { - return $this->order; - } - - public function withOrder(array $order = []): self - { - $self = clone $this; - $self->order = $order; - - return $self; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function withDescription(string $description = null): self - { - $self = clone $this; - $self->description = $description; - - return $self; - } - - public function getNormalizationContext(): ?array - { - return $this->normalizationContext; - } - - public function withNormalizationContext(array $normalizationContext = []): self - { - $self = clone $this; - $self->normalizationContext = $normalizationContext; - - return $self; - } - - public function getDenormalizationContext(): ?array - { - return $this->denormalizationContext; - } - - public function withDenormalizationContext(array $denormalizationContext = []): self - { - $self = clone $this; - $self->denormalizationContext = $denormalizationContext; - - return $self; - } - - public function getCollectDenormalizationErrors(): ?bool - { - return $this->collectDenormalizationErrors; - } - - public function withCollectDenormalizationErrors(bool $collectDenormalizationErrors = null): self - { - $self = clone $this; - $self->collectDenormalizationErrors = $collectDenormalizationErrors; - - return $self; - } - - public function getSecurity(): ?string - { - return $this->security; - } - - public function withSecurity(string $security = null): self - { - $self = clone $this; - $self->security = $security; - - return $self; - } - - public function getSecurityMessage(): ?string - { - return $this->securityMessage; - } - - public function withSecurityMessage(string $securityMessage = null): self - { - $self = clone $this; - $self->securityMessage = $securityMessage; - - return $self; - } - - public function getSecurityPostDenormalize(): ?string - { - return $this->securityPostDenormalize; - } - - public function withSecurityPostDenormalize(string $securityPostDenormalize = null): self - { - $self = clone $this; - $self->securityPostDenormalize = $securityPostDenormalize; - - return $self; - } - - public function getSecurityPostDenormalizeMessage(): ?string - { - return $this->securityPostDenormalizeMessage; - } - - public function withSecurityPostDenormalizeMessage(string $securityPostDenormalizeMessage = null): self - { - $self = clone $this; - $self->securityPostDenormalizeMessage = $securityPostDenormalizeMessage; - - return $self; - } - - public function getSecurityPostValidation(): ?string - { - return $this->securityPostValidation; - } - - public function withSecurityPostValidation(string $securityPostValidation = null): self - { - $self = clone $this; - $self->securityPostValidation = $securityPostValidation; - - return $self; - } - - public function getSecurityPostValidationMessage(): ?string - { - return $this->securityPostValidationMessage; - } - - public function withSecurityPostValidationMessage(string $securityPostValidationMessage = null): self - { - $self = clone $this; - $self->securityPostValidationMessage = $securityPostValidationMessage; - - return $self; - } - - public function getDeprecationReason(): ?string - { - return $this->deprecationReason; - } - - public function withDeprecationReason(string $deprecationReason = null): self - { - $self = clone $this; - $self->deprecationReason = $deprecationReason; - - return $self; - } - - public function getFilters(): ?array - { - return $this->filters; - } - - public function withFilters(array $filters = []): self - { - $self = clone $this; - $self->filters = $filters; - - return $self; - } - - public function getValidationContext(): ?array - { - return $this->validationContext; - } - - public function withValidationContext(array $validationContext = []): self - { - $self = clone $this; - $self->validationContext = $validationContext; - - return $self; - } - - public function getInput() - { - return $this->input; - } - - public function withInput($input = null): self - { - $self = clone $this; - $self->input = $input; - - return $self; - } - - public function getOutput() - { - return $this->output; - } - - public function withOutput($output = null): self - { - $self = clone $this; - $self->output = $output; - - return $self; - } - - public function getMercure() - { - return $this->mercure; - } - - public function withMercure($mercure = null): self - { - $self = clone $this; - $self->mercure = $mercure; - - return $self; - } - - public function getMessenger() - { - return $this->messenger; - } - - public function withMessenger($messenger = null): self - { - $self = clone $this; - $self->messenger = $messenger; - - return $self; - } - - public function getElasticsearch(): ?bool - { - return $this->elasticsearch; - } - - public function withElasticsearch(bool $elasticsearch = null): self - { - $self = clone $this; - $self->elasticsearch = $elasticsearch; - - return $self; - } - - public function getUrlGenerationStrategy(): ?int - { - return $this->urlGenerationStrategy; - } - - public function withUrlGenerationStrategy(int $urlGenerationStrategy = null): self - { - $self = clone $this; - $self->urlGenerationStrategy = $urlGenerationStrategy; - - return $self; - } - public function canRead(): ?bool { return $this->read; @@ -1261,32 +885,6 @@ public function withSerialize(bool $serialize = true): self return $self; } - public function getFetchPartial(): ?bool - { - return $this->fetchPartial; - } - - public function withFetchPartial(bool $fetchPartial = null): self - { - $self = clone $this; - $self->fetchPartial = $fetchPartial; - - return $self; - } - - public function getForceEager(): ?bool - { - return $this->forceEager; - } - - public function withForceEager(bool $forceEager = null): self - { - $self = clone $this; - $self->forceEager = $forceEager; - - return $self; - } - public function getPriority(): ?int { return $this->priority; @@ -1312,56 +910,4 @@ public function withName(string $name = ''): self return $self; } - - public function getProcessor(): callable|string|null - { - return $this->processor; - } - - public function withProcessor(callable|string|null $processor): self - { - $self = clone $this; - $self->processor = $processor; - - return $self; - } - - public function getProvider(): callable|string|null - { - return $this->provider; - } - - public function withProvider(callable|string|null $provider): self - { - $self = clone $this; - $self->provider = $provider; - - return $self; - } - - public function getExtraProperties(): array - { - return $this->extraProperties; - } - - public function withExtraProperties(array $extraProperties = []): self - { - $self = clone $this; - $self->extraProperties = $extraProperties; - - return $self; - } - - public function getStateOptions(): ?OptionsInterface - { - return $this->stateOptions; - } - - public function withStateOptions(?OptionsInterface $stateOptions): self - { - $self = clone $this; - $self->stateOptions = $stateOptions; - - return $self; - } } diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 7df114421ae..f13b72bbabf 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -94,7 +94,7 @@ public function __construct( array $extraProperties = [], ) { parent::__construct( - method: self::METHOD_PATCH, + method: 'PATCH', uriTemplate: $uriTemplate, types: $types, formats: $formats, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index f8a4fef3887..ab1ac133052 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -95,7 +95,7 @@ public function __construct( private ?string $itemUriTemplate = null ) { parent::__construct( - method: self::METHOD_POST, + method: 'POST', uriTemplate: $uriTemplate, types: $types, formats: $formats, diff --git a/src/Metadata/Property/Factory/IdentifierPropertyMetadataFactory.php b/src/Metadata/Property/Factory/IdentifierPropertyMetadataFactory.php index 5f17cfe01d7..272ef22db8b 100644 --- a/src/Metadata/Property/Factory/IdentifierPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/IdentifierPropertyMetadataFactory.php @@ -13,9 +13,9 @@ namespace ApiPlatform\Metadata\Property\Factory; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\ResourceClassResolverInterface; final class IdentifierPropertyMetadataFactory implements PropertyMetadataFactoryInterface { diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index d42b8d7f4bd..ef0f660f8b2 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -58,12 +58,17 @@ public function create(string $resourceClass, string $property, array $options = } $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups); + $types = $propertyMetadata->getBuiltinTypes() ?? []; - if (!$this->isResourceClass($resourceClass) && ($builtinType = $propertyMetadata->getBuiltinTypes()[0] ?? null) && $builtinType->isCollection()) { - return $propertyMetadata->withReadableLink(true)->withWritableLink(true); + if (!$this->isResourceClass($resourceClass) && $types) { + foreach ($types as $builtinType) { + if ($builtinType->isCollection()) { + return $propertyMetadata->withReadableLink(true)->withWritableLink(true); + } + } } - return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups); + return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $types); } /** @@ -101,48 +106,45 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformLinkStatus(ApiProperty $propertyMetadata, array $normalizationGroups = null, array $denormalizationGroups = null): ApiProperty + private function transformLinkStatus(ApiProperty $propertyMetadata, array $normalizationGroups = null, array $denormalizationGroups = null, array $types = null): ApiProperty { // No need to check link status if property is not readable and not writable if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { return $propertyMetadata; } - // TODO: 3.0 support multiple types, default value of types will be [] instead of null - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - - if (null === $type) { - return $propertyMetadata; - } + foreach ($types as $type) { + if ( + $type->isCollection() + && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null + ) { + $relatedClass = $collectionValueType->getClassName(); + } else { + $relatedClass = $type->getClassName(); + } - if ( - $type->isCollection() - && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null - ) { - $relatedClass = $collectionValueType->getClassName(); - } else { - $relatedClass = $type->getClassName(); - } + // if property is not a resource relation, don't set link status (as it would have no meaning) + if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { + continue; + } - // if property is not a resource relation, don't set link status (as it would have no meaning) - if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { - return $propertyMetadata; - } + // find the resource class + // this prevents serializer groups on non-resource child class from incorrectly influencing the decision + if (null !== $this->resourceClassResolver) { + $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); + } - // find the resource class - // this prevents serializer groups on non-resource child class from incorrectly influencing the decision - if (null !== $this->resourceClassResolver) { - $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); - } + $relatedGroups = $this->getClassSerializerGroups($relatedClass); - $relatedGroups = $this->getClassSerializerGroups($relatedClass); + if (null === $propertyMetadata->isReadableLink()) { + $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); + } - if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); - } + if (null === $propertyMetadata->isWritableLink()) { + $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + } - if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + return $propertyMetadata; } return $propertyMetadata; diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 05f91b100e3..aac7b295807 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -95,7 +95,7 @@ public function __construct( private ?bool $allowCreate = null, ) { parent::__construct( - method: self::METHOD_PUT, + method: 'PUT', uriTemplate: $uriTemplate, types: $types, formats: $formats, diff --git a/src/Metadata/Resource/Factory/ClassNameResourceNameCollectionFactory.php b/src/Metadata/Resource/Factory/ClassNameResourceNameCollectionFactory.php new file mode 100644 index 00000000000..16defb148a4 --- /dev/null +++ b/src/Metadata/Resource/Factory/ClassNameResourceNameCollectionFactory.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\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Resource\ResourceNameCollection; + +/** + * @internal + */ +final class ClassNameResourceNameCollectionFactory implements ResourceNameCollectionFactoryInterface +{ + /** + * @param class-string[] $classes + */ + public function __construct(private readonly array $classes, private readonly ?ResourceNameCollectionFactoryInterface $decorated = null) + { + } + + /** + * {@inheritdoc} + */ + public function create(): ResourceNameCollection + { + $classes = $this->classes; + if ($this->decorated) { + foreach ($this->decorated->create() as $resourceClass) { + $classes[] = $resourceClass; + } + } + + return new ResourceNameCollection($classes); + } +} diff --git a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php index 93ff0007743..69bf28cddc5 100644 --- a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php @@ -14,9 +14,9 @@ namespace ApiPlatform\Metadata\Resource\Factory; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -33,7 +33,7 @@ */ final class FormatsResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { - public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated, private readonly array $formats, private readonly array $patchFormats) + public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated, private readonly array $formats, private readonly array $patchFormats, private readonly ?array $errorFormats = null) { } @@ -53,6 +53,9 @@ public function create(string $resourceClass): ResourceMetadataCollection $resourceFormats = null === $rawResourceFormats ? $this->formats : $this->normalizeFormats($rawResourceFormats); $resourceInputFormats = $resourceMetadata->getInputFormats() ? $this->normalizeFormats($resourceMetadata->getInputFormats()) : $resourceFormats; $resourceOutputFormats = $resourceMetadata->getOutputFormats() ? $this->normalizeFormats($resourceMetadata->getOutputFormats()) : $resourceFormats; + if ($resourceMetadata instanceof ErrorResource) { + $resourceOutputFormats = $resourceMetadata->getOutputFormats() ? $this->normalizeFormats($resourceMetadata->getOutputFormats()) : ($this->errorFormats ?? []); + } $resourceMetadataCollection[$index] = $resourceMetadataCollection[$index]->withOperations($this->normalize($resourceInputFormats, $resourceOutputFormats, $resourceMetadata->getOperations())); } @@ -69,7 +72,7 @@ private function normalize(array $resourceInputFormats, array $resourceOutputFor $operation = $operation->withFormats($this->normalizeFormats($operation->getFormats())); } - if (($isPatch = HttpOperation::METHOD_PATCH === $operation->getMethod()) && !$operation->getFormats() && !$operation->getInputFormats()) { + if (($isPatch = 'PATCH' === $operation->getMethod()) && !$operation->getFormats() && !$operation->getInputFormats()) { $operation = $operation->withInputFormats($this->patchFormats); } diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index 49ff2eeba40..3420aa4647c 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -13,10 +13,9 @@ namespace ApiPlatform\Metadata\Resource\Factory; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Metadata; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -34,7 +33,7 @@ public function __construct(private readonly PropertyNameCollectionFactoryInterf /** * {@inheritdoc} */ - public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link + public function createLinkFromProperty(Metadata $operation, string $property): Link { $metadata = $this->propertyMetadataFactory->create($resourceClass = $operation->getClass(), $property); $relationClass = $this->getPropertyClassType($metadata->getBuiltinTypes()); @@ -50,7 +49,7 @@ public function createLinkFromProperty(ApiResource|Operation $operation, string /** * {@inheritdoc} */ - public function createLinksFromIdentifiers(ApiResource|Operation $operation): array + public function createLinksFromIdentifiers(Metadata $operation): array { $identifiers = $this->getIdentifiersFromResourceClass($resourceClass = $operation->getClass()); @@ -72,7 +71,7 @@ public function createLinksFromIdentifiers(ApiResource|Operation $operation): ar /** * {@inheritdoc} */ - public function createLinksFromRelations(ApiResource|Operation $operation): array + public function createLinksFromRelations(Metadata $operation): array { $links = []; foreach ($this->propertyNameCollectionFactory->create($resourceClass = $operation->getClass()) as $property) { @@ -93,7 +92,7 @@ public function createLinksFromRelations(ApiResource|Operation $operation): arra /** * {@inheritdoc} */ - public function createLinksFromAttributes(ApiResource|Operation $operation): array + public function createLinksFromAttributes(Metadata $operation): array { $links = []; try { diff --git a/src/Metadata/Resource/Factory/LinkFactoryInterface.php b/src/Metadata/Resource/Factory/LinkFactoryInterface.php index 9424861b0cd..0368521e7c0 100644 --- a/src/Metadata/Resource/Factory/LinkFactoryInterface.php +++ b/src/Metadata/Resource/Factory/LinkFactoryInterface.php @@ -13,9 +13,8 @@ namespace ApiPlatform\Metadata\Resource\Factory; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Metadata; /** * @internal @@ -27,21 +26,21 @@ interface LinkFactoryInterface * * @return Link[] */ - public function createLinksFromIdentifiers(ApiResource|Operation $operation); + public function createLinksFromIdentifiers(Metadata $operation); /** * Create Links from the relations metadata information. * * @return Link[] */ - public function createLinksFromRelations(ApiResource|Operation $operation); + public function createLinksFromRelations(Metadata $operation); /** * Create Links by using PHP attribute Links found on properties. * * @return Link[] */ - public function createLinksFromAttributes(ApiResource|Operation $operation): array; + public function createLinksFromAttributes(Metadata $operation): array; /** * Complete a link with identifiers information. diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php index de23c4a6e8e..45a4d14003b 100644 --- a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -62,7 +62,7 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($operations as $operation) { // An item operation has been found, nothing to do anymore in this factory - if ((HttpOperation::METHOD_GET === $operation->getMethod() && !$operation instanceof CollectionOperationInterface) || ($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false)) { + if (('GET' === $operation->getMethod() && !$operation instanceof CollectionOperationInterface) || ($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false)) { return $resourceMetadataCollection; } } diff --git a/src/Metadata/Resource/Factory/PropertyLinkFactoryInterface.php b/src/Metadata/Resource/Factory/PropertyLinkFactoryInterface.php index f5a550901a6..080717535be 100644 --- a/src/Metadata/Resource/Factory/PropertyLinkFactoryInterface.php +++ b/src/Metadata/Resource/Factory/PropertyLinkFactoryInterface.php @@ -13,9 +13,8 @@ namespace ApiPlatform\Metadata\Resource\Factory; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Metadata; /** * @internal @@ -25,5 +24,5 @@ interface PropertyLinkFactoryInterface /** * Create a link for a given property. */ - public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link; + public function createLinkFromProperty(Metadata $operation, string $property): Link; } diff --git a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php index 85099af0e78..56708711e8e 100644 --- a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php @@ -148,7 +148,7 @@ private function configureUriVariables(ApiResource|HttpOperation $operation): Ap $operation = $this->normalizeUriVariables($operation); if (!($uriTemplate = $operation->getUriTemplate())) { - if ($operation instanceof HttpOperation && HttpOperation::METHOD_POST === $operation->getMethod()) { + if ($operation instanceof HttpOperation && 'POST' === $operation->getMethod()) { return $operation->withUriVariables([]); } @@ -201,7 +201,7 @@ private function configureUriVariables(ApiResource|HttpOperation $operation): Ap } // We generated this operation but there're some missing identifiers - $uriVariables = HttpOperation::METHOD_POST === $operation->getMethod() || $operation instanceof CollectionOperationInterface ? [] : $operation->getUriVariables(); + $uriVariables = 'POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface ? [] : $operation->getUriVariables(); foreach ($diff as $key) { $uriVariables[$key] = $this->linkFactory->createLinkFromProperty($operation, $key); diff --git a/src/Metadata/Resource/ResourceMetadataCollection.php b/src/Metadata/Resource/ResourceMetadataCollection.php index 3ba3a10b062..2a6c526ac7c 100644 --- a/src/Metadata/Resource/ResourceMetadataCollection.php +++ b/src/Metadata/Resource/ResourceMetadataCollection.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Exception\OperationNotFoundException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; /** @@ -59,8 +58,8 @@ public function getOperation(string $operationName = null, bool $forceCollection foreach ($metadata->getOperations() ?? [] as $name => $operation) { $isCollection = $operation instanceof CollectionOperationInterface; - $method = $operation->getMethod() ?? HttpOperation::METHOD_GET; - $isGetOperation = HttpOperation::METHOD_GET === $method || HttpOperation::METHOD_OPTIONS === $method || HttpOperation::METHOD_HEAD === $method; + $method = $operation->getMethod() ?? 'GET'; + $isGetOperation = 'GET' === $method || 'OPTIONS' === $method || 'HEAD' === $method; if ('' === $operationName && $isGetOperation && ($forceCollection ? $isCollection : !$isCollection)) { return $this->operationCache[$httpCacheKey] = $operation; } @@ -89,9 +88,9 @@ public function getOperation(string $operationName = null, bool $forceCollection } // Idea: - // if ($metadata) { - // return (new class extends HttpOperation {})->withResource($metadata); - // } + // if ($metadata) { + // return (new class extends HttpOperation {})->withResource($metadata); + // } $this->handleNotFound($operationName, $metadata); } diff --git a/src/Metadata/ResourceClassResolverInterface.php b/src/Metadata/ResourceClassResolverInterface.php index 4a4adeb18ec..8dabc533502 100644 --- a/src/Metadata/ResourceClassResolverInterface.php +++ b/src/Metadata/ResourceClassResolverInterface.php @@ -37,3 +37,5 @@ public function getResourceClass(mixed $value, string $resourceClass = null, boo */ public function isResourceClass(string $type): bool; } + +class_alias(ResourceClassResolverInterface::class, \ApiPlatform\Api\ResourceClassResolverInterface::class); diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 54b94f79d9e..69f73065b3b 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -410,6 +410,16 @@ private function buildArgs(\SimpleXMLElement $resource, array $args): void } } + private function buildExtraArgs(\SimpleXMLElement $resource, array $args): void + { + $child = $resource->addChild('extraArgs'); + foreach ($args as $id => $values) { + $grandChild = $child->addChild('arg'); + $grandChild->addAttribute('id', $id); + $this->buildValues($grandChild, $values); + } + } + private function buildGraphQlOperations(\SimpleXMLElement $resource, array $values): void { $node = $resource->addChild('graphQlOperations'); diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index 934d77882bc..bcb20426525 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -182,6 +182,12 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'bar' => 'baz', ], ], + 'extraArgs' => [ + 'bar' => [ + 'type' => 'custom', + 'baz' => 'qux', + ], + ], 'shortName' => self::SHORT_NAME, 'description' => 'Creates a Comment.', 'class' => Mutation::class, diff --git a/src/Metadata/Tests/Fixtures/ApiResource/DummyWithEnumIdentifier.php b/src/Metadata/Tests/Fixtures/ApiResource/DummyWithEnumIdentifier.php new file mode 100644 index 00000000000..8c4e3d633ae --- /dev/null +++ b/src/Metadata/Tests/Fixtures/ApiResource/DummyWithEnumIdentifier.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests\Fixtures\ApiResource; + +class DummyWithEnumIdentifier +{ + public $id; + + public function __construct( + public StringEnumAsIdentifier $stringEnumAsIdentifier = StringEnumAsIdentifier::FOO, + public IntEnumAsIdentifier $intEnumAsIdentifier = IntEnumAsIdentifier::FOO, + ) { + } +} + +enum IntEnumAsIdentifier: int +{ + case FOO = 1; + case BAR = 2; +} + +enum StringEnumAsIdentifier: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} diff --git a/src/Metadata/Tests/Fixtures/ApiResource/RelationMultiple.php b/src/Metadata/Tests/Fixtures/ApiResource/RelationMultiple.php new file mode 100644 index 00000000000..69488222e9a --- /dev/null +++ b/src/Metadata/Tests/Fixtures/ApiResource/RelationMultiple.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\Metadata\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\State\RelationMultipleProvider; + +#[ApiResource( + mercure: true, + operations: [ + new Post(), + new Get( + uriTemplate: '/dummy/{firstId}/relations/{secondId}', + uriVariables: [ + 'firstId' => new Link( + fromClass: Dummy::class, + toProperty: 'first', + identifiers: ['id'], + ), + 'secondId' => new Link( + fromClass: Dummy::class, + toProperty: 'second', + identifiers: ['id'], + ), + ], + provider: RelationMultipleProvider::class, + ), + new GetCollection( + uriTemplate : '/dummy/{firstId}/relations', + uriVariables: [ + 'firstId' => new Link( + fromClass: Dummy::class, + toProperty: 'first', + identifiers: ['id'], + ), + ], + provider: RelationMultipleProvider::class, + ), + ] +)] + +class RelationMultiple +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + public ?Dummy $first = null; + public ?Dummy $second = null; +} diff --git a/src/Metadata/Tests/Fixtures/Metadata/Get.php b/src/Metadata/Tests/Fixtures/Metadata/Get.php index e2ae82d4c26..df60e4f9625 100644 --- a/src/Metadata/Tests/Fixtures/Metadata/Get.php +++ b/src/Metadata/Tests/Fixtures/Metadata/Get.php @@ -23,6 +23,6 @@ final class Get extends HttpOperation */ public function __construct(...$args) { - parent::__construct(self::METHOD_GET, ...$args); + parent::__construct('GET', ...$args); } } diff --git a/src/Metadata/Tests/Fixtures/State/RelationMultipleProvider.php b/src/Metadata/Tests/Fixtures/State/RelationMultipleProvider.php new file mode 100644 index 00000000000..5f8ae004747 --- /dev/null +++ b/src/Metadata/Tests/Fixtures/State/RelationMultipleProvider.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\Metadata\Tests\Fixtures\State; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; + +class RelationMultipleProvider implements ProviderInterface +{ + /** + * {@inheritDoc} + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object|null + { + $firstDummy = new Dummy(); + $firstDummy->setId($uriVariables['firstId']); + $secondDummy = new Dummy(); + $relationMultiple = new RelationMultiple(); + $relationMultiple->id = 1; + $relationMultiple->first = $firstDummy; + $relationMultiple->second = $secondDummy; + + if ($operation instanceof GetCollection) { + $secondDummy->setId(2); + $thirdDummy = new Dummy(); + $thirdDummy->setId(3); + $relationMultiple2 = new RelationMultiple(); + $relationMultiple2->id = 2; + $relationMultiple2->first = $firstDummy; + $relationMultiple2->second = $thirdDummy; + + return [$relationMultiple, $relationMultiple2]; + } + + $relationMultiple->second->setId($uriVariables['secondId']); + + return $relationMultiple; + } +} diff --git a/tests/Api/IdentifiersExtractorTest.php b/src/Metadata/Tests/IdentifiersExtractorTest.php similarity index 95% rename from tests/Api/IdentifiersExtractorTest.php rename to src/Metadata/Tests/IdentifiersExtractorTest.php index db7cd50e06c..88de9d6fa32 100644 --- a/tests/Api/IdentifiersExtractorTest.php +++ b/src/Metadata/Tests/IdentifiersExtractorTest.php @@ -11,20 +11,20 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Api; +namespace ApiPlatform\Metadata\Tests; -use ApiPlatform\Api\IdentifiersExtractor; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IdentifiersExtractor; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithEnumIdentifier; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; -use ApiPlatform\Tests\Fixtures\TestBundle\State\RelationMultipleProvider; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\DummyWithEnumIdentifier; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\RelationMultiple; +use ApiPlatform\Metadata\Tests\Fixtures\State\RelationMultipleProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/src/Metadata/UriVariableTransformerInterface.php b/src/Metadata/UriVariableTransformerInterface.php new file mode 100644 index 00000000000..e91bfc6d7f6 --- /dev/null +++ b/src/Metadata/UriVariableTransformerInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use ApiPlatform\Exception\InvalidUriVariableException; + +interface UriVariableTransformerInterface +{ + /** + * Transforms the value of a URI variable (identifier) to its type. + * + * @param mixed $value The URI variable value to transform + * @param array $types The guessed type behind the URI variable + * @param array $context Options available to the transformer + * + * @throws InvalidUriVariableException Occurs when the URI variable could not be transformed + */ + public function transform(mixed $value, array $types, array $context = []); + + /** + * Checks whether the value of a URI variable can be transformed to its type by this transformer. + * + * @param mixed $value The URI variable value to transform + * @param array $types The types to which the URI variable value should be transformed + * @param array $context Options available to the transformer + */ + public function supportsTransformation(mixed $value, array $types, array $context = []): bool; +} diff --git a/src/Metadata/UriVariablesConverter.php b/src/Metadata/UriVariablesConverter.php new file mode 100644 index 00000000000..db838be38e6 --- /dev/null +++ b/src/Metadata/UriVariablesConverter.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\Metadata; + +use ApiPlatform\Metadata\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\PropertyInfo\Type; + +/** + * UriVariables converter that chains uri variables transformers. + * + * @author Antoine Bluchet + */ +final class UriVariablesConverter implements UriVariablesConverterInterface +{ + /** + * @param iterable $uriVariableTransformers + */ + public function __construct(private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly iterable $uriVariableTransformers) + { + } + + /** + * {@inheritdoc} + * + * To handle the composite identifiers type correctly, use an `uri_variables_map` that maps uriVariables to their uriVariablesDefinition. + * Indeed, a composite identifier will already be parsed, and their corresponding properties will be the parameterName and not the defined + * identifiers. + */ + public function convert(array $uriVariables, string $class, array $context = []): array + { + $operation = $context['operation'] ?? $this->resourceMetadataCollectionFactory->create($class)->getOperation(); + $context += ['operation' => $operation]; + $uriVariablesDefinitions = $operation->getUriVariables() ?? []; + + foreach ($uriVariables as $parameterName => $value) { + $uriVariableDefinition = $context['uri_variables_map'][$parameterName] ?? $uriVariablesDefinitions[$parameterName] ?? $uriVariablesDefinitions['id'] ?? new Link(); + + // When a composite identifier is used, we assume that the parameterName is the property to find our type + $properties = $uriVariableDefinition->getIdentifiers() ?? [$parameterName]; + if ($uriVariableDefinition->getCompositeIdentifier()) { + $properties = [$parameterName]; + } + + if (!$types = $this->getIdentifierTypes($uriVariableDefinition->getFromClass() ?? $class, $properties)) { + continue; + } + + foreach ($this->uriVariableTransformers as $uriVariableTransformer) { + if (!$uriVariableTransformer->supportsTransformation($value, $types, $context)) { + continue; + } + + try { + $uriVariables[$parameterName] = $uriVariableTransformer->transform($value, $types, $context); + break; + } catch (InvalidUriVariableException $e) { + throw new InvalidUriVariableException(sprintf('Identifier "%s" could not be transformed.', $parameterName), $e->getCode(), $e); + } + } + } + + return $uriVariables; + } + + private function getIdentifierTypes(string $resourceClass, array $properties): array + { + $types = []; + foreach ($properties as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + foreach ($propertyMetadata->getBuiltinTypes() as $type) { + $types[] = Type::BUILTIN_TYPE_OBJECT === ($builtinType = $type->getBuiltinType()) ? $type->getClassName() : $builtinType; + } + } + + return $types; + } +} diff --git a/src/Metadata/UriVariablesConverterInterface.php b/src/Metadata/UriVariablesConverterInterface.php new file mode 100644 index 00000000000..22ba1e7214e --- /dev/null +++ b/src/Metadata/UriVariablesConverterInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use ApiPlatform\Metadata\Exception\InvalidIdentifierException; + +/** + * Identifier converter. + * + * @author Antoine Bluchet + */ +interface UriVariablesConverterInterface +{ + /** + * Takes an array of strings representing URI variables (identifiers) and transform their values to the expected type. + * + * @param array $data URI variables to convert to PHP values + * @param string $class The class to which the URI variables belong to + * + * @throws InvalidIdentifierException + * + * @return array Array indexed by identifiers properties with their values denormalized + */ + public function convert(array $data, string $class, array $context = []): array; +} diff --git a/src/Metadata/UrlGeneratorInterface.php b/src/Metadata/UrlGeneratorInterface.php new file mode 100644 index 00000000000..5df27ef16e4 --- /dev/null +++ b/src/Metadata/UrlGeneratorInterface.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +use Symfony\Component\Routing\Exception\InvalidParameterException; +use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; +use Symfony\Component\Routing\Exception\RouteNotFoundException; + +/** + * UrlGeneratorInterface is the interface that all URL generator classes must implement. + * + * This interface has been imported and adapted from the Symfony project. + * + * The constants in this interface define the different types of resource references that + * are declared in RFC 3986: http://tools.ietf.org/html/rfc3986 + * We are using the term "URL" instead of "URI" as this is more common in web applications + * and we do not need to distinguish them as the difference is mostly semantical and + * less technical. Generating URIs, i.e. representation-independent resource identifiers, + * is also possible. + * + * @author Fabien Potencier + * @author Tobias Schultze + * @copyright Fabien Potencier + */ +interface UrlGeneratorInterface +{ + /** + * Generates an absolute URL, e.g. "http://example.com/dir/file". + */ + public const ABS_URL = 0; + + /** + * Generates an absolute path, e.g. "/dir/file". + */ + public const ABS_PATH = 1; + + /** + * Generates a relative path based on the current request path, e.g. "../parent-file". + * + * @see UrlGenerator::getRelativePath() + */ + public const REL_PATH = 2; + + /** + * Generates a network path, e.g. "//example.com/dir/file". + * Such reference reuses the current scheme but specifies the host. + */ + public const NET_PATH = 3; + + /** + * Generates a URL or path for a specific route based on the given parameters. + * + * Parameters that reference placeholders in the route pattern will substitute them in the + * path or host. Extra params are added as query string to the URL. + * + * When the passed reference type cannot be generated for the route because it requires a different + * host or scheme than the current one, the method will return a more comprehensive reference + * that includes the required params. For example, when you call this method with $referenceType = ABSOLUTE_PATH + * but the route requires the https scheme whereas the current scheme is http, it will instead return an + * ABSOLUTE_URL with the https scheme and the current host. This makes sure the generated URL matches + * the route in any case. + * + * If there is no route with the given name, the generator must throw the RouteNotFoundException. + * + * The special parameter _fragment will be used as the document fragment suffixed to the final URL. + * + * @throws RouteNotFoundException If the named route doesn't exist + * @throws MissingMandatoryParametersException When some parameters are missing that are mandatory for the route + * @throws InvalidParameterException When a parameter value for a placeholder is not correct because + * it does not match the requirement + */ + public function generate(string $name, array $parameters = [], int $referenceType = self::ABS_PATH): string; +} diff --git a/src/Metadata/Util/AttributesExtractor.php b/src/Metadata/Util/AttributesExtractor.php new file mode 100644 index 00000000000..99b5cee8b13 --- /dev/null +++ b/src/Metadata/Util/AttributesExtractor.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\Metadata\Util; + +/** + * Extracts data used by the library form given attributes. + * + * @author Antoine Bluchet + * + * @internal + */ +final class AttributesExtractor +{ + private function __construct() + { + } + + /** + * Extracts resource class, operation name and format request attributes. Returns an empty array if the request does + * not contain required attributes. + */ + public static function extractAttributes(array $attributes): array + { + $result = ['resource_class' => $attributes['_api_resource_class'] ?? null, 'has_composite_identifier' => $attributes['_api_has_composite_identifier'] ?? false]; + + if (null === $result['resource_class']) { + return []; + } + + $hasRequestAttributeKey = false; + if (isset($attributes['_api_operation_name'])) { + $hasRequestAttributeKey = true; + $result['operation_name'] = $attributes['_api_operation_name']; + } + if (isset($attributes['_api_operation'])) { + $result['operation'] = $attributes['_api_operation']; + } + + if ($previousObject = $attributes['previous_data'] ?? null) { + $result['previous_data'] = $previousObject; + } + + if (false === $hasRequestAttributeKey) { + return []; + } + + $result += [ + 'receive' => (bool) ($attributes['_api_receive'] ?? true), + 'respond' => (bool) ($attributes['_api_respond'] ?? true), + 'persist' => (bool) ($attributes['_api_persist'] ?? true), + ]; + + return $result; + } +} diff --git a/src/Metadata/Util/CloneTrait.php b/src/Metadata/Util/CloneTrait.php new file mode 100644 index 00000000000..75b903fe9ce --- /dev/null +++ b/src/Metadata/Util/CloneTrait.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Util; + +/** + * Clones given data if cloneable. + * + * @internal + * + * @author Quentin Barloy + */ +trait CloneTrait +{ + public function clone(mixed $data): mixed + { + if (!\is_object($data)) { + return $data; + } + + try { + return (new \ReflectionClass($data))->isCloneable() ? clone $data : null; + } catch (\ReflectionException) { + return null; + } + } +} diff --git a/src/Metadata/Util/CompositeIdentifierParser.php b/src/Metadata/Util/CompositeIdentifierParser.php new file mode 100644 index 00000000000..f433cdf0abe --- /dev/null +++ b/src/Metadata/Util/CompositeIdentifierParser.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Util; + +/** + * Normalizes a composite identifier. + * + * @internal + * + * @author Antoine Bluchet + */ +final class CompositeIdentifierParser +{ + public const COMPOSITE_IDENTIFIER_REGEXP = '/(\w+)=(?<=\w=)(.*?)(?=;\w+=)|(\w+)=([^;]*);?$/'; + + private function __construct() + { + } + + /* + * Normalize takes a string and gives back an array of identifiers. + * + * For example: foo=0;bar=2 returns ['foo' => 0, 'bar' => 2]. + */ + public static function parse(string $identifier): array + { + $matches = []; + $identifiers = []; + $num = preg_match_all(self::COMPOSITE_IDENTIFIER_REGEXP, $identifier, $matches, \PREG_SET_ORDER); + + foreach ($matches as $i => $match) { + if ($i === $num - 1) { + $identifiers[$match[3]] = $match[4]; + continue; + } + $identifiers[$match[1]] = $match[2]; + } + + return $identifiers; + } + + /** + * Renders composite identifiers to string using: key=value;key2=value2. + */ + public static function stringify(array $identifiers): string + { + $composite = []; + foreach ($identifiers as $name => $value) { + $composite[] = sprintf('%s=%s', $name, $value); + } + + return implode(';', $composite); + } +} diff --git a/src/Metadata/Util/Inflector.php b/src/Metadata/Util/Inflector.php index 4d9c9501f23..3c35f3d49cb 100644 --- a/src/Metadata/Util/Inflector.php +++ b/src/Metadata/Util/Inflector.php @@ -13,22 +13,28 @@ namespace ApiPlatform\Metadata\Util; -use Doctrine\Inflector\Inflector as InflectorObject; +use Doctrine\Inflector\Inflector as LegacyInflector; use Doctrine\Inflector\InflectorFactory; +use Symfony\Component\String\Inflector\EnglishInflector; +use Symfony\Component\String\UnicodeString; /** - * Facade for Doctrine Inflector. - * * @internal */ final class Inflector { - private static ?InflectorObject $instance = null; + private static bool $keepLegacyInflector = true; + private static ?LegacyInflector $instance = null; + + private static function getInstance(): LegacyInflector + { + return static::$instance + ?? static::$instance = InflectorFactory::create()->build(); + } - private static function getInstance(): InflectorObject + public static function keepLegacyInflector(bool $keepLegacyInflector): void { - return self::$instance - ?? self::$instance = InflectorFactory::create()->build(); + static::$keepLegacyInflector = $keepLegacyInflector; } /** @@ -36,7 +42,11 @@ private static function getInstance(): InflectorObject */ public static function tableize(string $word): string { - return self::getInstance()->tableize($word); + if (!static::$keepLegacyInflector) { + return (new UnicodeString($word))->snake()->toString(); + } + + return static::getInstance()->tableize($word); } /** @@ -44,6 +54,12 @@ public static function tableize(string $word): string */ public static function pluralize(string $word): string { + if (!static::$keepLegacyInflector) { + $pluralize = (new EnglishInflector())->pluralize($word); + + return end($pluralize); + } + return self::getInstance()->pluralize($word); } } diff --git a/src/Metadata/Util/ResourceClassInfoTrait.php b/src/Metadata/Util/ResourceClassInfoTrait.php index 4c5f5126825..1c13550eed8 100644 --- a/src/Metadata/Util/ResourceClassInfoTrait.php +++ b/src/Metadata/Util/ResourceClassInfoTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Util; +use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -25,7 +26,10 @@ trait ResourceClassInfoTrait { use ClassInfoTrait; - private ?ResourceClassResolverInterface $resourceClassResolver = null; + /** + * @var LegacyResourceClassResolverInterface|ResourceClassResolverInterface|null + */ + private $resourceClassResolver; private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null; /** diff --git a/src/Metadata/Util/SortTrait.php b/src/Metadata/Util/SortTrait.php new file mode 100644 index 00000000000..612dc262a51 --- /dev/null +++ b/src/Metadata/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\Metadata\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/src/Metadata/WithResourceTrait.php b/src/Metadata/WithResourceTrait.php index f546447c7e0..60d4939f8c3 100644 --- a/src/Metadata/WithResourceTrait.php +++ b/src/Metadata/WithResourceTrait.php @@ -15,12 +15,7 @@ trait WithResourceTrait { - public function withResource(ApiResource $resource): self - { - return $this->copyFrom($resource); - } - - protected function copyFrom(ApiResource|Operation $resource): ApiResource|Operation + protected function copyFrom(Metadata $resource): static { $self = clone $this; foreach (get_class_methods($resource) as $method) { diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 3e4aaa86a33..05b3720efa3 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -31,7 +31,8 @@ "doctrine/inflector": "^2.0", "psr/cache": "^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/property-info": "^6.1" + "symfony/property-info": "^6.1", + "symfony/string": "^6.1" }, "require-dev": { "phpstan/phpdoc-parser": "^1.16", diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 0b396476005..bb5117d1dac 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -155,7 +155,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $path = $this->getPath($path); - $method = $operation->getMethod() ?? HttpOperation::METHOD_GET; + $method = $operation->getMethod() ?? 'GET'; if (!\in_array($method, PathItem::$methods, true)) { continue; @@ -272,7 +272,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiOperation->withParameter($parameter); } - if ($operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $method) { + if ($operation instanceof CollectionOperationInterface && 'POST' !== $method) { foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) { if ($this->hasParameter($openapiOperation, $parameter)) { continue; @@ -285,11 +285,11 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation?->getResponses() ?: []; // Create responses switch ($method) { - case HttpOperation::METHOD_GET: + case 'GET': $successStatus = (string) $operation->getStatus() ?: 200; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas); break; - case HttpOperation::METHOD_POST: + case 'POST': $successStatus = (string) $operation->getStatus() ?: 201; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); @@ -298,8 +298,8 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); break; - case HttpOperation::METHOD_PATCH: - case HttpOperation::METHOD_PUT: + case 'PATCH': + case 'PUT': $successStatus = (string) $operation->getStatus() ?: 200; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); @@ -308,7 +308,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); break; - case HttpOperation::METHOD_DELETE: + case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); @@ -316,7 +316,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection break; } - if (!$operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $operation->getMethod()) { + if (!$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { if (!isset($existingResponses[404])) { $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); } @@ -346,7 +346,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection 'The "openapiContext" option is deprecated, use "openapi" instead.' ); $openapiOperation = $openapiOperation->withRequestBody(new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false)); - } elseif (null === $openapiOperation->getRequestBody() && \in_array($method, [HttpOperation::METHOD_PATCH, HttpOperation::METHOD_PUT, HttpOperation::METHOD_POST], true)) { + } elseif (null === $openapiOperation->getRequestBody() && \in_array($method, ['PATCH', 'PUT', 'POST'], true)) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); @@ -354,7 +354,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', HttpOperation::METHOD_POST === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); } // TODO Remove in 4.0 @@ -498,12 +498,12 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operationName => $operation) { $parameters = []; - $method = $operation instanceof HttpOperation ? $operation->getMethod() : HttpOperation::METHOD_GET; + $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if ( $operationName === $operation->getName() || isset($links[$operationName]) || $operation instanceof CollectionOperationInterface - || HttpOperation::METHOD_GET !== $method + || 'GET' !== $method ) { continue; } diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index 8a95e18421c..abd2c015501 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -22,9 +22,7 @@ final class OpenApi { use ExtensionTrait; - // We're actually supporting 3.1 but swagger ui has a version constraint - // public const VERSION = '3.1.0'; - public const VERSION = '3.0.0'; + public const VERSION = '3.1.0'; private string $openapi = self::VERSION; diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index b4c0b96822c..8d4c5dfef0a 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -85,7 +85,7 @@ public function testInvoke(): void 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'customDummyItem' => (new HttpOperation())->withMethod(HttpOperation::METHOD_HEAD)->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( + 'customDummyItem' => (new HttpOperation())->withMethod('HEAD')->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( tags: ['Dummy', 'Profile'], responses: [ '202' => new OpenApiResponse( @@ -254,34 +254,116 @@ public function testInvoke(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'readOnly' => true, 'description' => 'This is an id.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$', 'description' => 'This is a name.', 'type' => 'string']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => ['string', 'null'], 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -329,8 +411,9 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + $schemaFactory = new SchemaFactory(null, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); $factory = new OpenApiFactory( @@ -379,10 +462,9 @@ public function testInvoke(): void 'description' => 'This is an initializable but not writable property.', ]), 'dummy_date' => new \ArrayObject([ - 'type' => 'string', + 'type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time', - 'nullable' => true, ]), 'enum' => new \ArrayObject([ 'type' => 'string', diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index 9a5c9244f1a..2ee459952b7 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -151,6 +151,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -162,7 +163,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) - ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -174,6 +175,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'readOnly' => true, 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -185,6 +187,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( @@ -194,6 +197,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -201,8 +205,9 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); $factory = new OpenApiFactory( diff --git a/src/Problem/Serializer/ErrorNormalizer.php b/src/Problem/Serializer/ErrorNormalizer.php index 0bd6d9fd3e0..39093aec65f 100644 --- a/src/Problem/Serializer/ErrorNormalizer.php +++ b/src/Problem/Serializer/ErrorNormalizer.php @@ -46,6 +46,7 @@ public function __construct(private readonly bool $debug = false, array $default */ public function normalize(mixed $object, string $format = null, array $context = []): array { + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__)); $data = [ 'type' => $context[self::TYPE] ?? $this->defaultContext[self::TYPE], 'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE], @@ -64,6 +65,10 @@ public function normalize(mixed $object, string $format = null, array $context = */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { + if ($context['skip_deprecated_exception_normalizers'] ?? false) { + return false; + } + return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); } @@ -71,8 +76,8 @@ public function getSupportedTypes($format): array { if (self::FORMAT === $format) { return [ - \Exception::class => true, - FlattenException::class => true, + \Exception::class => false, + FlattenException::class => false, ]; } @@ -90,6 +95,6 @@ public function hasCacheableSupportsMethod(): bool ); } - return true; + return false; } } diff --git a/src/RamseyUuid/.gitignore b/src/RamseyUuid/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/RamseyUuid/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/RamseyUuid/LICENSE b/src/RamseyUuid/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/RamseyUuid/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/RamseyUuid/README.md b/src/RamseyUuid/README.md new file mode 100644 index 00000000000..a3540188e79 --- /dev/null +++ b/src/RamseyUuid/README.md @@ -0,0 +1,3 @@ +# API Platform - RamseyUuid + +RamseyUuid component from API Platform diff --git a/tests/RamseyUuid/Serializer/UuidDenormalizerTest.php b/src/RamseyUuid/Tests/Serializer/UuidDenormalizerTest.php similarity index 100% rename from tests/RamseyUuid/Serializer/UuidDenormalizerTest.php rename to src/RamseyUuid/Tests/Serializer/UuidDenormalizerTest.php diff --git a/tests/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformerTest.php b/src/RamseyUuid/Tests/UriVariableTransformer/UuidUriVariableTransformerTest.php similarity index 96% rename from tests/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformerTest.php rename to src/RamseyUuid/Tests/UriVariableTransformer/UuidUriVariableTransformerTest.php index 2dd2a4bcefe..f9bdb04c95d 100644 --- a/tests/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformerTest.php +++ b/src/RamseyUuid/Tests/UriVariableTransformer/UuidUriVariableTransformerTest.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\RamseyUuid\UriVariableTransformer; -use ApiPlatform\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\RamseyUuid\UriVariableTransformer\UuidUriVariableTransformer; use PHPUnit\Framework\TestCase; use Ramsey\Uuid\Uuid; diff --git a/src/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformer.php b/src/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformer.php index 70522f0a8d7..15c51e50ac5 100644 --- a/src/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformer.php +++ b/src/RamseyUuid/UriVariableTransformer/UuidUriVariableTransformer.php @@ -13,8 +13,8 @@ namespace ApiPlatform\RamseyUuid\UriVariableTransformer; -use ApiPlatform\Api\UriVariableTransformerInterface; -use ApiPlatform\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\UriVariableTransformerInterface; use Ramsey\Uuid\Exception\InvalidUuidStringException; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json new file mode 100644 index 00000000000..4fca943077f --- /dev/null +++ b/src/RamseyUuid/composer.json @@ -0,0 +1,62 @@ +{ + "name": "api-platform/ramsey-uuid", + "description": "API Platform RamseyUuid support", + "type": "library", + "keywords": [ + "UUid", + "API" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "symfony/serializer": "^6.1" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1", + "ramsey/uuid": "^3.7 || ^4.0", + "ramsey/uuid-doctrine": "^1.4" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\RamseyUuid\\": "" + } + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + } + ] +} diff --git a/src/RamseyUuid/phpunit.xml.dist b/src/RamseyUuid/phpunit.xml.dist new file mode 100644 index 00000000000..d6eb5a25764 --- /dev/null +++ b/src/RamseyUuid/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + + diff --git a/src/Serializer/.gitignore b/src/Serializer/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/Serializer/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 3a1bdd0eaf1..142015324f1 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -13,19 +13,20 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Exception\InvalidArgumentException; use ApiPlatform\Exception\ItemNotFoundException; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; -use ApiPlatform\Util\CloneTrait; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -402,8 +403,7 @@ protected function getAllowedAttributes(string|object $classOrObject, array $con if ( $this->isAllowedAttribute($classOrObject, $propertyName, null, $context) - && ( - isset($context['api_normalize']) && $propertyMetadata->isReadable() + && (isset($context['api_normalize']) && $propertyMetadata->isReadable() || isset($context['api_denormalize']) && ($propertyMetadata->isWritable() || !\is_object($classOrObject) && $propertyMetadata->isInitializable()) ) ) { @@ -512,12 +512,7 @@ protected function denormalizeCollection(string $attribute, ApiProperty $propert $collectionKeyType = $type->getCollectionKeyTypes()[0] ?? null; $collectionKeyBuiltinType = $collectionKeyType?->getBuiltinType(); - $childContext = $this->createChildContext(['resource_class' => $className] + $context, $attribute, $format); - unset($childContext['uri_variables']); - if ($this->resourceMetadataCollectionFactory) { - $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($className)->getOperation(); - } - + $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format); $values = []; foreach ($value as $index => $obj) { if (null !== $collectionKeyBuiltinType && !\call_user_func('is_'.$collectionKeyBuiltinType, $index)) { @@ -623,44 +618,61 @@ protected function getAttributeValue(object $object, string $attribute, string $ return $attributeValue; } - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes() ?? []; - if ( - $type - && $type->isCollection() - && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) - && ($className = $collectionValueType->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - if (!is_iterable($attributeValue)) { - throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); + foreach ($types as $type) { + if ( + $type->isCollection() + && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) + && ($className = $collectionValueType->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + if (!is_iterable($attributeValue)) { + throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); + } + + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format); + + return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + if ( + ($className = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + if (!\is_object($attributeValue) && null !== $attributeValue) { + throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); + } - return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); - } + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format); - if ( - $type - && ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - if (!\is_object($attributeValue) && null !== $attributeValue) { - throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); + return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['resource_class'] = $resourceClass; - if ($this->resourceMetadataCollectionFactory) { - $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + if (!$this->serializer instanceof NormalizerInterface) { + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); + } + + unset( + $context['resource_class'], + $context['force_resource_class'], + ); + + // Anonymous resources + if ($type->getClassName()) { + $childContext = $this->createChildContext($this->createOperationContext($context, null), $attribute, $format); + $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true; + + return $this->serializer->normalize($attributeValue, $format, $childContext); } - unset($childContext['iri'], $childContext['uri_variables']); - return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); + if ('array' === $type->getBuiltinType()) { + $childContext = $this->createChildContext($this->createOperationContext($context, null), $attribute, $format); + + return $this->serializer->normalize($attributeValue, $format, $childContext); + } } if (!$this->serializer instanceof NormalizerInterface) { @@ -670,21 +682,6 @@ protected function getAttributeValue(object $object, string $attribute, string $ unset($context['resource_class']); unset($context['force_resource_class']); - if ($type && $type->getClassName()) { - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables']); - $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true; - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - - if ($type && 'array' === $type->getBuiltinType()) { - $childContext = $this->createChildContext($context, $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables']); - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - return $this->serializer->normalize($attributeValue, $format, $context); } @@ -768,122 +765,142 @@ private function createAttributeValue(string $attribute, mixed $value, string $f private function createAndValidateAttributeValue(string $attribute, mixed $value, string $format = null, array $context = []): mixed { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + $types = $propertyMetadata->getBuiltinTypes() ?? []; + $isMultipleTypes = \count($types) > 1; - if (null === $type) { - // No type provided, blindly return the value - return $value; - } + foreach ($types as $type) { + if (null === $value && ($type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { + return $value; + } - if (null === $value && $type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false)) { - return $value; - } + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ + // Fix a collection that contains the only one element + // This is special to xml format only + if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) { + $value = [$value]; + } - /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ - // Fix a collection that contains the only one element - // This is special to xml format only - if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) { - $value = [$value]; - } + if ( + $type->isCollection() + && null !== $collectionValueType + && null !== ($className = $collectionValueType->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); + $context['resource_class'] = $resourceClass; - if ( - $type->isCollection() - && null !== $collectionValueType - && null !== ($className = $collectionValueType->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); + return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); + } - return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); - } + if ( + null !== ($className = $type->getClassName()) + && $this->resourceClassResolver->isResourceClass($className) + ) { + $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); + $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format); - if ( - null !== ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); - $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['resource_class'] = $resourceClass; - unset($childContext['uri_variables']); - if ($this->resourceMetadataCollectionFactory) { - $childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); } - return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); - } + if ( + $type->isCollection() + && null !== $collectionValueType + && null !== ($className = $collectionValueType->getClassName()) + && \is_array($value) + ) { + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + } - if ( - $type->isCollection() - && null !== $collectionValueType - && null !== ($className = $collectionValueType->getClassName()) - ) { - if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + unset($context['resource_class']); + + return $this->serializer->denormalize($value, $className.'[]', $format, $context); } - unset($context['resource_class']); + if (null !== $className = $type->getClassName()) { + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + } - return $this->serializer->denormalize($value, $className.'[]', $format, $context); - } + unset($context['resource_class']); - if (null !== $className = $type->getClassName()) { - if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + return $this->serializer->denormalize($value, $className, $format, $context); } - unset($context['resource_class']); + /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ + // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine, + // if a value is meant to be a string, float, int or a boolean value from the serialized representation. + // That's why we have to transform the values, if one of these non-string basic datatypes is expected. + if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { + if ('' === $value && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) { + return null; + } - return $this->serializer->denormalize($value, $className, $format, $context); - } + switch ($type->getBuiltinType()) { + case Type::BUILTIN_TYPE_BOOL: + // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" + if ('false' === $value || '0' === $value) { + $value = false; + } elseif ('true' === $value || '1' === $value) { + $value = true; + } else { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + break 2; + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null); + } + break; + case Type::BUILTIN_TYPE_INT: + if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) { + $value = (int) $value; + } else { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + break 2; + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null); + } + break; + case Type::BUILTIN_TYPE_FLOAT: + if (is_numeric($value)) { + return (float) $value; + } - /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ - // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine, - // if a value is meant to be a string, float, int or a boolean value from the serialized representation. - // That's why we have to transform the values, if one of these non-string basic datatypes is expected. - if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { - if ('' === $value && $type->isNullable() && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true)) { - return null; + switch ($value) { + case 'NaN': + return \NAN; + case 'INF': + return \INF; + case '-INF': + return -\INF; + default: + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + break 3; + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null); + } + } } - switch ($type->getBuiltinType()) { - case Type::BUILTIN_TYPE_BOOL: - // according to http://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" - if ('false' === $value || '0' === $value) { - $value = false; - } elseif ('true' === $value || '1' === $value) { - $value = true; - } else { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null); - } - break; - case Type::BUILTIN_TYPE_INT: - if (ctype_digit($value) || ('-' === $value[0] && ctype_digit(substr($value, 1)))) { - $value = (int) $value; - } else { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null); - } - break; - case Type::BUILTIN_TYPE_FLOAT: - if (is_numeric($value)) { - return (float) $value; - } - - return match ($value) { - 'NaN' => \NAN, - 'INF' => \INF, - '-INF' => -\INF, - default => throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $className, $value), $value, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null), - }; + if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { + return $value; } - } - if ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false) { // @phpstan-ignore-line - return $value; - } + try { + $this->validateType($attribute, $type, $value, $format, $context); - $this->validateType($attribute, $type, $value, $format, $context); + break; + } catch (NotNormalizableValueException $e) { + // union/intersect types: try the next type + if (!$isMultipleTypes) { + throw $e; + } + } + } return $value; } @@ -899,4 +916,29 @@ private function setValue(object $object, string $attributeName, mixed $value): // Properties not found are ignored } } + + private function createOperationContext(array $context, string $resourceClass = null): array + { + if (isset($context['operation']) && !isset($context['root_operation'])) { + $context['root_operation'] = $context['operation']; + $context['root_operation_name'] = $context['operation_name']; + } + + unset($context['iri'], $context['uri_variables']); + if (!$resourceClass) { + return $context; + } + + unset($context['operation'], $context['operation_name']); + $context['resource_class'] = $resourceClass; + if ($this->resourceMetadataCollectionFactory) { + try { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); + $context['operation_name'] = $context['operation']->getName(); + } catch (OperationNotFoundException) { + } + } + + return $context; + } } diff --git a/src/Serializer/Filter/FilterInterface.php b/src/Serializer/Filter/FilterInterface.php index 7093ad22c3e..22868870e4b 100644 --- a/src/Serializer/Filter/FilterInterface.php +++ b/src/Serializer/Filter/FilterInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Serializer\Filter; -use ApiPlatform\Api\FilterInterface as BaseFilterInterface; +use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use Symfony\Component\HttpFoundation\Request; /** diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index c51f911e1d0..e93d5c79608 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -13,14 +13,15 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Exception\InvalidArgumentException as LegacyInvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -75,7 +76,7 @@ private function updateObjectToPopulate(array $data, array &$context): void { try { $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); - } catch (InvalidArgumentException) { + } catch (LegacyInvalidArgumentException|InvalidArgumentException) { $operation = $this->resourceMetadataCollectionFactory->create($context['resource_class'])->getOperation(); $uriVariables = $this->getContextUriVariables($data, $operation, $context); $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); @@ -86,11 +87,7 @@ private function updateObjectToPopulate(array $data, array &$context): void private function getContextUriVariables(array $data, $operation, array $context): array { - if (!isset($context['uri_variables'])) { - return ['id' => $data['id']]; - } - - $uriVariables = $context['uri_variables']; + $uriVariables = $context['uri_variables'] ?? $data; /** @var Link $uriVariable */ foreach ($operation->getUriVariables() as $uriVariable) { diff --git a/src/Serializer/LICENSE b/src/Serializer/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/Serializer/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Serializer/README.md b/src/Serializer/README.md new file mode 100644 index 00000000000..10b0b8c8ae1 --- /dev/null +++ b/src/Serializer/README.md @@ -0,0 +1,3 @@ +# API Platform - GraphQL + +Build GraphQL API endpoints diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 2fa07c3a1c0..1ed459f9143 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -13,9 +13,9 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Util\RequestAttributesExtractor; +use ApiPlatform\Symfony\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; diff --git a/src/Serializer/SerializerFilterContextBuilder.php b/src/Serializer/SerializerFilterContextBuilder.php index cf5246a02ba..452fe399984 100644 --- a/src/Serializer/SerializerFilterContextBuilder.php +++ b/src/Serializer/SerializerFilterContextBuilder.php @@ -13,10 +13,10 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\Filter\FilterInterface; -use ApiPlatform\Util\RequestAttributesExtractor; +use ApiPlatform\Symfony\Util\RequestAttributesExtractor; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php similarity index 99% rename from tests/Serializer/AbstractItemNormalizerTest.php rename to src/Serializer/Tests/AbstractItemNormalizerTest.php index 011936da0e0..8bfea62e752 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -11,24 +11,24 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer; +namespace ApiPlatform\Serializer\Tests; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DtoWithNullValue; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritance; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceChild; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceRelated; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\RelatedDummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\SecuredDummy; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5584\DtoWithNullValue; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NonCloneableDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; diff --git a/tests/Serializer/Filter/GroupFilterTest.php b/src/Serializer/Tests/Filter/GroupFilterTest.php similarity index 99% rename from tests/Serializer/Filter/GroupFilterTest.php rename to src/Serializer/Tests/Filter/GroupFilterTest.php index 168ffec3319..fd8707d9e53 100644 --- a/tests/Serializer/Filter/GroupFilterTest.php +++ b/src/Serializer/Tests/Filter/GroupFilterTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer\Filter; +namespace ApiPlatform\Serializer\Tests\Filter; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; diff --git a/tests/Serializer/Filter/PropertyFilterTest.php b/src/Serializer/Tests/Filter/PropertyFilterTest.php similarity index 98% rename from tests/Serializer/Filter/PropertyFilterTest.php rename to src/Serializer/Tests/Filter/PropertyFilterTest.php index 05f0954e6e7..d529e5afc76 100644 --- a/tests/Serializer/Filter/PropertyFilterTest.php +++ b/src/Serializer/Tests/Filter/PropertyFilterTest.php @@ -11,11 +11,11 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer\Filter; +namespace ApiPlatform\Serializer\Tests\Filter; use ApiPlatform\Serializer\Filter\PropertyFilter; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; -use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyProperty; +use ApiPlatform\Serializer\Tests\Fixtures\Serializer\NameConverter\CustomConverter; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DtoWithNullValue.php b/src/Serializer/Tests/Fixtures/ApiResource/DtoWithNullValue.php new file mode 100644 index 00000000000..1183c93e489 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DtoWithNullValue.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\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * Issue #5584. + */ +#[ApiResource(denormalizationContext: [AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true])] +final class DtoWithNullValue +{ + public \stdClass $dummy; +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/Dummy.php b/src/Serializer/Tests/Fixtures/ApiResource/Dummy.php new file mode 100644 index 00000000000..748d9338578 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/Dummy.php @@ -0,0 +1,250 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; + +/** + * Dummy. + * + * @author Kévin Dunglas + */ +#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] +class Dummy +{ + /** + * @var int|null The id + */ + private $id; + + /** + * @var string The dummy name + */ + #[ApiProperty(iris: ['https://schema.org/name'])] + private string $name; + + /** + * @var string|null The dummy name alias + */ + #[ApiProperty(iris: ['https://schema.org/alternateName'])] + private $alias; + + /** + * @var array foo + */ + private ?array $foo = null; + + /** + * @var string|null A short description of the item + */ + #[ApiProperty(iris: ['https://schema.org/description'])] + public $description; + + /** + * @var string|null A dummy + */ + public $dummy; + + /** + * @var bool|null A dummy boolean + */ + public ?bool $dummyBoolean = null; + + /** + * @var \DateTime|null A dummy date + */ + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + public $dummyDate; + + /** + * @var float|null A dummy float + */ + public $dummyFloat; + + /** + * @var string|null A dummy price + */ + public $dummyPrice; + + #[ApiProperty(push: true)] + public ?RelatedDummy $relatedDummy = null; + + public Collection|iterable $relatedDummies; + + /** + * @var array|null serialize data + */ + public $jsonData = []; + + /** + * @var array|null + */ + public $arrayData = []; + + /** + * @var string|null + */ + public $nameConverted; + + public static function staticMethod(): void + { + } + + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function setAlias($alias): void + { + $this->alias = $alias; + } + + public function getAlias() + { + return $this->alias; + } + + public function setDescription($description): void + { + $this->description = $description; + } + + public function getDescription() + { + return $this->description; + } + + public function fooBar($baz): void + { + } + + public function getFoo(): ?array + { + return $this->foo; + } + + public function setFoo(array $foo = null): void + { + $this->foo = $foo; + } + + public function setDummyDate(\DateTime $dummyDate = null): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function setDummyPrice($dummyPrice) + { + $this->dummyPrice = $dummyPrice; + + return $this; + } + + public function getDummyPrice() + { + return $this->dummyPrice; + } + + public function setJsonData($jsonData): void + { + $this->jsonData = $jsonData; + } + + public function getJsonData() + { + return $this->jsonData; + } + + public function setArrayData($arrayData): void + { + $this->arrayData = $arrayData; + } + + public function getArrayData() + { + return $this->arrayData; + } + + public function getRelatedDummy(): ?RelatedDummy + { + return $this->relatedDummy; + } + + public function setRelatedDummy(RelatedDummy $relatedDummy): void + { + $this->relatedDummy = $relatedDummy; + } + + public function addRelatedDummy(RelatedDummy $relatedDummy): void + { + $this->relatedDummies->add($relatedDummy); + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean($dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function setDummy($dummy = null): void + { + $this->dummy = $dummy; + } + + public function getDummy() + { + return $this->dummy; + } + + public function getRelatedDummies(): Collection|iterable + { + return $this->relatedDummies; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DummyGroup.php b/src/Serializer/Tests/Fixtures/ApiResource/DummyGroup.php new file mode 100644 index 00000000000..0f17d035155 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DummyGroup.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\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * DummyGroup. + * + * @author Baptiste Meyer + */ +#[ApiResource(graphQlOperations: [new Query(name: 'item_query', normalizationContext: ['groups' => ['dummy_foo']]), new QueryCollection(name: 'collection_query', normalizationContext: ['groups' => ['dummy_foo']]), new Mutation(name: 'delete'), new Mutation(name: 'create', normalizationContext: ['groups' => ['dummy_bar']], denormalizationContext: ['groups' => ['dummy_bar', 'dummy_baz']])], normalizationContext: ['groups' => ['dummy_read']], denormalizationContext: ['groups' => ['dummy_write']], filters: ['dummy_group.group', 'dummy_group.override_group', 'dummy_group.whitelist_group', 'dummy_group.override_whitelist_group'])] +class DummyGroup +{ + #[Groups(['dummy', 'dummy_read', 'dummy_id'])] + private ?int $id = null; + /** + * @var string|null + */ + #[Groups(['dummy', 'dummy_read', 'dummy_write', 'dummy_foo'])] + public $foo; + /** + * @var string|null + */ + #[Groups(['dummy', 'dummy_read', 'dummy_write', 'dummy_bar'])] + public $bar; + /** + * @var string|null + */ + #[Groups(['dummy', 'dummy_read', 'dummy_baz'])] + public $baz; + /** + * @var string|null + */ + #[Groups(['dummy', 'dummy_write', 'dummy_qux'])] + public $qux; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DummyProperty.php b/src/Serializer/Tests/Fixtures/ApiResource/DummyProperty.php new file mode 100644 index 00000000000..039377c021b --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DummyProperty.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * DummyProperty. + * + * @author Baptiste Meyer + */ +#[ApiResource(graphQlOperations: [new Query(name: 'item_query'), new QueryCollection(name: 'collection_query'), new Mutation(name: 'update'), new Mutation(name: 'delete'), new Mutation(name: 'create', normalizationContext: ['groups' => ['dummy_graphql_read']])], normalizationContext: ['groups' => ['dummy_read']], denormalizationContext: ['groups' => ['dummy_write']], filters: ['dummy_property.property', 'dummy_property.whitelist_property', 'dummy_property.whitelisted_properties'])] +class DummyProperty +{ + #[Groups(['dummy_read', 'dummy_graphql_read'])] + private ?int $id = null; + /** + * @var string|null + */ + #[Groups(['dummy_read', 'dummy_write'])] + public $foo; + /** + * @var string|null + */ + #[Groups(['dummy_read', 'dummy_graphql_read', 'dummy_write'])] + public $bar; + /** + * @var string|null + */ + #[Groups(['dummy_read', 'dummy_graphql_read', 'dummy_write'])] + public $baz; + /** + * @var DummyGroup|null + */ + #[Groups(['dummy_read', 'dummy_graphql_read', 'dummy_write'])] + public $group; + #[Groups(['dummy_read', 'dummy_graphql_read', 'dummy_write'])] + public Collection|iterable|null $groups = null; + /** + * @var string|null + */ + #[Groups(['dummy_read'])] + public $nameConverted; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritance.php b/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritance.php new file mode 100644 index 00000000000..6a1f0a515fe --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritance.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource] +class DummyTableInheritance +{ + /** + * @var int|null The id + */ + #[Groups(['default'])] + private ?int $id = null; + /** + * @var string The dummy name + */ + #[Groups(['default'])] + private string $name; + private ?DummyTableInheritanceRelated $parent = null; + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getParent(): ?DummyTableInheritanceRelated + { + return $this->parent; + } + + public function setParent(?DummyTableInheritanceRelated $parent): self + { + $this->parent = $parent; + + return $this; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritanceChild.php b/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritanceChild.php new file mode 100644 index 00000000000..f8cb66570fb --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritanceChild.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource] +class DummyTableInheritanceChild extends DummyTableInheritance +{ + /** + * @var string The dummy nickname + */ + #[Groups(['default'])] + private $nickname; + + public function getNickname() + { + return $this->nickname; + } + + public function setNickname($nickname): void + { + $this->nickname = $nickname; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritanceRelated.php b/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritanceRelated.php new file mode 100644 index 00000000000..ae5e4176f03 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DummyTableInheritanceRelated.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(normalizationContext: ['groups' => ['default']], denormalizationContext: ['groups' => ['default']])] +class DummyTableInheritanceRelated +{ + /** + * @var int The id + */ + #[Groups(['default'])] + private ?int $id = null; + /** + * @var Collection Related children + */ + #[Groups(['default'])] + private Collection|iterable $children; + + public function __construct() + { + $this->children = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getChildren(): Collection|iterable + { + return $this->children; + } + + public function setChildren(Collection|iterable $children) + { + $this->children = $children; + + return $this; + } + + public function addChild($child) + { + $this->children->add($child); + $child->setParent($this); + + return $this; + } + + public function removeChild($child) + { + $this->children->remove($child); + + return $this; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/NonCloneableDummy.php b/src/Serializer/Tests/Fixtures/ApiResource/NonCloneableDummy.php new file mode 100644 index 00000000000..f0e6d86b2f2 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/NonCloneableDummy.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Dummy class that cannot be cloned. + * + * @author Colin O'Dell + */ +#[ApiResource] +class NonCloneableDummy +{ + /** + * @var int|null The id + */ + private $id; + + /** + * @var string The dummy name + */ + #[ApiProperty(iris: ['http://schema.org/name'])] + #[Assert\NotBlank] + private $name; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + private function __clone() + { + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/RelatedDummy.php b/src/Serializer/Tests/Fixtures/ApiResource/RelatedDummy.php new file mode 100644 index 00000000000..1af14a8ed44 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/RelatedDummy.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Link; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Related Dummy. + * + * @author Kévin Dunglas + */ +#[ApiResource( + graphQlOperations: [ + new Query(name: 'item_query'), + new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), + ], + types: ['https://schema.org/Product'], + normalizationContext: ['groups' => ['friends']], + filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'] +)] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] +#[ApiResource(uriTemplate: '/related_dummies/{id}/id{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] +class RelatedDummy implements \Stringable +{ + #[ApiProperty(writable: false)] + #[Groups(['chicago', 'friends'])] + private $id; + + /** + * @var string|null A name + */ + #[ApiProperty(iris: ['RelatedDummy.name'])] + #[Groups(['friends'])] + public $name; + + #[ApiProperty(deprecationReason: 'This property is deprecated for upgrade test')] + #[Groups(['barcelona', 'chicago', 'friends'])] + protected $symfony = 'symfony'; + + /** + * @var \DateTime|null A dummy date + */ + #[Assert\DateTime] + #[Groups(['friends'])] + public $dummyDate; + + /** + * @var bool|null A dummy bool + */ + #[Groups(['friends'])] + public ?bool $dummyBoolean = null; + + public function __construct() + { + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function setName($name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getSymfony() + { + return $this->symfony; + } + + public function setSymfony($symfony): void + { + $this->symfony = $symfony; + } + + public function setDummyDate(\DateTime $dummyDate): void + { + $this->dummyDate = $dummyDate; + } + + public function getDummyDate() + { + return $this->dummyDate; + } + + public function isDummyBoolean(): ?bool + { + return $this->dummyBoolean; + } + + /** + * @param bool $dummyBoolean + */ + public function setDummyBoolean($dummyBoolean): void + { + $this->dummyBoolean = $dummyBoolean; + } + + public function __toString(): string + { + return (string) $this->getId(); + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/SecuredDummy.php b/src/Serializer/Tests/Fixtures/ApiResource/SecuredDummy.php new file mode 100644 index 00000000000..1b02f8a721f --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/SecuredDummy.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * Secured resource. + * + * @author Kévin Dunglas + */ +#[ApiResource(operations: [ + new Get(security: 'is_granted(\'ROLE_USER\') and object.getOwner() == user'), + new Put(securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user', extraProperties: ['standard_put' => false]), + new GetCollection(security: 'is_granted(\'ROLE_USER\') or is_granted(\'ROLE_ADMIN\')'), + new GetCollection(uriTemplate: 'custom_data_provider_generator', security: 'is_granted(\'ROLE_USER\')'), + new Post(security: 'is_granted(\'ROLE_ADMIN\')'), +], + graphQlOperations: [ + new Query(name: 'item_query', security: 'is_granted(\'ROLE_ADMIN\') or (is_granted(\'ROLE_USER\') and object.getOwner() == user)'), + new QueryCollection(name: 'collection_query', security: 'is_granted(\'ROLE_ADMIN\')'), + new Mutation(name: 'delete'), + new Mutation(name: 'update', securityPostDenormalize: 'is_granted(\'ROLE_USER\') and previous_object.getOwner() == user'), + new Mutation(name: 'create', security: 'is_granted(\'ROLE_ADMIN\')', securityMessage: 'Only admins can create a secured dummy.'), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +class SecuredDummy +{ + private ?int $id = null; + + /** + * @var string The title + */ + #[Assert\NotBlank] + private string $title; + + /** + * @var string The description + */ + private string $description = ''; + + /** + * @var string The dummy secret property, only readable/writable by specific users + */ + #[ApiProperty(security: "is_granted('ROLE_ADMIN')")] + private string $adminOnlyProperty = ''; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.getOwner() == user', securityPostDenormalize: 'object.getOwner() == user')] + private string $ownerOnlyProperty = ''; + + /** + * @var string The owner + */ + #[Assert\NotBlank] + private string $owner; + + /** + * A collection of dummies that only admins can access. + */ + #[ApiProperty(security: "is_granted('ROLE_ADMIN')")] + public Collection|iterable $relatedDummies; + + /** + * A dummy that only admins can access. + * + * @var RelatedDummy|null + */ + #[ApiProperty(security: "is_granted('ROLE_ADMIN')")] + protected $relatedDummy; + + /** + * A collection of dummies that only users can access. The security on RelatedSecuredDummy shouldn't be run. + */ + #[ApiProperty(security: "is_granted('ROLE_USER')")] + public Collection|iterable $relatedSecuredDummies; + + /** + * A dummy that only users can access. The security on RelatedSecuredDummy shouldn't be run. + */ + #[ApiProperty(security: "is_granted('ROLE_USER')")] + protected $relatedSecuredDummy; + + /** + * Collection of dummies that anyone can access. There is no ApiProperty security, and the security on RelatedSecuredDummy shouldn't be run. + */ + public iterable $publicRelatedSecuredDummies; + + /** + * A dummy that anyone can access. There is no ApiProperty security, and the security on RelatedSecuredDummy shouldn't be run. + */ + protected $publicRelatedSecuredDummy; + + public function __construct() + { + $this->relatedDummies = []; + $this->relatedSecuredDummies = []; + $this->publicRelatedSecuredDummies = []; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } + + public function getAdminOnlyProperty(): ?string + { + return $this->adminOnlyProperty; + } + + public function setAdminOnlyProperty(?string $adminOnlyProperty): void + { + $this->adminOnlyProperty = $adminOnlyProperty; + } + + public function getOwnerOnlyProperty(): ?string + { + return $this->ownerOnlyProperty; + } + + public function setOwnerOnlyProperty(?string $ownerOnlyProperty): void + { + $this->ownerOnlyProperty = $ownerOnlyProperty; + } + + public function getOwner(): ?string + { + return $this->owner; + } + + public function setOwner(string $owner): void + { + $this->owner = $owner; + } +} diff --git a/src/Serializer/Tests/Fixtures/Serializer/NameConverter/CustomConverter.php b/src/Serializer/Tests/Fixtures/Serializer/NameConverter/CustomConverter.php new file mode 100644 index 00000000000..72332e4f0cb --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Serializer/NameConverter/CustomConverter.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\Serializer\NameConverter; + +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +/** + * Custom converter that will only convert a property named "nameConverted" + * with the same logic as Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. + */ +final class CustomConverter extends CamelCaseToSnakeCaseNameConverter +{ + public function normalize(string $propertyName): string + { + return 'nameConverted' === $propertyName ? parent::normalize($propertyName) : $propertyName; + } + + public function denormalize(string $propertyName): string + { + return 'name_converted' === $propertyName ? parent::denormalize($propertyName) : $propertyName; + } +} diff --git a/tests/Serializer/ItemNormalizerTest.php b/src/Serializer/Tests/ItemNormalizerTest.php similarity index 98% rename from tests/Serializer/ItemNormalizerTest.php rename to src/Serializer/Tests/ItemNormalizerTest.php index 310094964d5..b33f833b99c 100644 --- a/tests/Serializer/ItemNormalizerTest.php +++ b/src/Serializer/Tests/ItemNormalizerTest.php @@ -11,15 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer; +namespace ApiPlatform\Serializer\Tests; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -27,8 +25,10 @@ use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\ItemNormalizer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/Serializer/JsonEncoderTest.php b/src/Serializer/Tests/JsonEncoderTest.php similarity index 97% rename from tests/Serializer/JsonEncoderTest.php rename to src/Serializer/Tests/JsonEncoderTest.php index b47d59f8198..129b35753fd 100644 --- a/tests/Serializer/JsonEncoderTest.php +++ b/src/Serializer/Tests/JsonEncoderTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer; +namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Serializer\JsonEncoder; use PHPUnit\Framework\TestCase; diff --git a/tests/Serializer/ResourceListTest.php b/src/Serializer/Tests/ResourceListTest.php similarity index 94% rename from tests/Serializer/ResourceListTest.php rename to src/Serializer/Tests/ResourceListTest.php index aed86d28f17..b87821e45ac 100644 --- a/tests/Serializer/ResourceListTest.php +++ b/src/Serializer/Tests/ResourceListTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer; +namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Serializer\ResourceList; use PHPUnit\Framework\TestCase; diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/src/Serializer/Tests/SerializerContextBuilderTest.php similarity index 99% rename from tests/Serializer/SerializerContextBuilderTest.php rename to src/Serializer/Tests/SerializerContextBuilderTest.php index f8ee15a8bd4..dacc366a9fb 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/src/Serializer/Tests/SerializerContextBuilderTest.php @@ -11,10 +11,10 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer; +namespace ApiPlatform\Serializer\Tests; -use ApiPlatform\Exception\RuntimeException; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Patch; diff --git a/tests/Serializer/SerializerFilterContextBuilderTest.php b/src/Serializer/Tests/SerializerFilterContextBuilderTest.php similarity index 98% rename from tests/Serializer/SerializerFilterContextBuilderTest.php rename to src/Serializer/Tests/SerializerFilterContextBuilderTest.php index 82832a07a08..8a4c497d876 100644 --- a/tests/Serializer/SerializerFilterContextBuilderTest.php +++ b/src/Serializer/Tests/SerializerFilterContextBuilderTest.php @@ -11,18 +11,18 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Serializer; +namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Api\FilterInterface; -use ApiPlatform\Exception\RuntimeException; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Serializer\SerializerFilterContextBuilder; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyGroup; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json new file mode 100644 index 00000000000..279255c7410 --- /dev/null +++ b/src/Serializer/composer.json @@ -0,0 +1,78 @@ +{ + "name": "api-platform/serializer", + "description": "Build GraphQL API endpoints", + "type": "library", + "keywords": [ + "Serializer", + "API" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", + "doctrine/collections": "^2.1", + "symfony/property-access": "^6.3", + "symfony/property-info": "^6.1", + "symfony/serializer": "^6.1", + "symfony/validator": "^6.3" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1", + "symfony/mercure-bundle": "*", + "api-platform/symfony": "*@dev || ^3.1" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + }, + { + "type": "path", + "url": "../State" + }, + { + "type": "path", + "url": "../Symfony" + } + ] +} diff --git a/src/Serializer/phpunit.xml.dist b/src/Serializer/phpunit.xml.dist new file mode 100644 index 00000000000..0e1e002f892 --- /dev/null +++ b/src/Serializer/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/State/CreateProvider.php b/src/State/CreateProvider.php index ad1d6dc094e..7343b37c18d 100644 --- a/src/State/CreateProvider.php +++ b/src/State/CreateProvider.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Post; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; diff --git a/src/State/DefaultErrorProvider.php b/src/State/DefaultErrorProvider.php new file mode 100644 index 00000000000..f7f08d8d1cc --- /dev/null +++ b/src/State/DefaultErrorProvider.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\State; + +use ApiPlatform\Metadata\Operation; + +/** + * @internal + */ +final class DefaultErrorProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + return $context['previous_data']; + } +} diff --git a/src/State/UriVariablesResolverTrait.php b/src/State/UriVariablesResolverTrait.php index 61a54a2cc75..b9e222aab0a 100644 --- a/src/State/UriVariablesResolverTrait.php +++ b/src/State/UriVariablesResolverTrait.php @@ -13,10 +13,10 @@ namespace ApiPlatform\State; -use ApiPlatform\Api\CompositeIdentifierParser; -use ApiPlatform\Api\UriVariablesConverterInterface; -use ApiPlatform\Exception\InvalidIdentifierException; +use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; trait UriVariablesResolverTrait { diff --git a/src/Symfony/.gitignore b/src/Symfony/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/Symfony/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 4da1d65147c..b2395b2fa13 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -15,6 +15,7 @@ use ApiPlatform\Api\FilterInterface; use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\ApiResource\Error; use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface; use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter as DoctrineMongoDbOdmAbstractFilter; @@ -30,9 +31,11 @@ use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\Symfony\GraphQl\Resolver\Factory\DataCollectorResolverFactory; +use ApiPlatform\Symfony\Validator\Exception\ValidationException; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface; use Doctrine\Persistence\ManagerRegistry; @@ -104,6 +107,10 @@ public function load(array $configs, ContainerBuilder $container): void $patchFormats = $this->getFormats($config['patch_formats']); $errorFormats = $this->getFormats($config['error_formats']); + if (!isset($errorFormats['html']) && $config['enable_swagger'] && $config['enable_swagger_ui']) { + $errorFormats['html'] = ['text/html']; + } + // Backward Compatibility layer if (isset($formats['jsonapi']) && !isset($patchFormats['jsonapi'])) { $patchFormats['jsonapi'] = ['application/vnd.api+json']; @@ -142,6 +149,8 @@ public function load(array $configs, ContainerBuilder $container): void if (!$container->has('api_platform.state.item_provider')) { $container->setAlias('api_platform.state.item_provider', 'api_platform.state_provider.object'); } + + $this->registerInflectorConfiguration($config); } private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats): void @@ -161,6 +170,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); + $container->setParameter('api_platform.keep_legacy_inflector', $config['keep_legacy_inflector']); $container->setParameter('api_platform.title', $config['title']); $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); @@ -254,6 +264,8 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra { [$xmlResources, $yamlResources] = $this->getResourcesToWatch($container, $config); + $container->setParameter('api_platform.class_name_resources', $this->getClassNameResources()); + $loader->load('metadata/resource_name.xml'); $loader->load('metadata/property_name.xml'); @@ -284,6 +296,14 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra } } + private function getClassNameResources(): array + { + return [ + Error::class, + ValidationException::class, + ]; + } + private function getBundlesResourcesPaths(ContainerBuilder $container, array $config): array { $bundlesResourcesPaths = []; @@ -479,6 +499,8 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array { $enabled = $this->isConfigEnabled($container, $config['graphql']); + $graphqlIntrospectionEnabled = $enabled && $this->isConfigEnabled($container, $config['graphql']['introspection']); + $graphiqlEnabled = $enabled && $this->isConfigEnabled($container, $config['graphql']['graphiql']); $graphqlPlayGroundEnabled = $enabled && $this->isConfigEnabled($container, $config['graphql']['graphql_playground']); if ($graphqlPlayGroundEnabled) { @@ -486,6 +508,7 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array } $container->setParameter('api_platform.graphql.enabled', $enabled); + $container->setParameter('api_platform.graphql.introspection.enabled', $graphqlIntrospectionEnabled); $container->setParameter('api_platform.graphql.graphiql.enabled', $graphiqlEnabled); $container->setParameter('api_platform.graphql.graphql_playground.enabled', $graphqlPlayGroundEnabled); $container->setParameter('api_platform.graphql.collection.pagination', $config['graphql']['collection']['pagination']); @@ -777,4 +800,14 @@ private function registerArgumentResolverConfiguration(XmlFileLoader $loader): v { $loader->load('argument_resolver.xml'); } + + private function registerInflectorConfiguration(array $config): void + { + if ($config['keep_legacy_inflector']) { + Inflector::keepLegacyInflector(true); + trigger_deprecation('api-platform/core', '3.2', 'Using doctrine/inflector is deprecated since API Platform 3.2 and will be removed in API Platform 4. Use symfony/string instead. Run "composer require symfony/string" and set "keep_legacy_inflector" to false in config.'); + } else { + Inflector::keepLegacyInflector(false); + } + } } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 45a7c3b32c6..063d66a58d7 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -109,6 +109,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() + ->booleanNode('keep_legacy_inflector')->defaultTrue()->info('Keep doctrine/inflector instead of symfony/string to generate plurals for routes.')->end() ->arrayNode('collection') ->addDefaultsIfNotSet() ->children() @@ -235,6 +236,9 @@ private function addGraphQlSection(ArrayNodeDefinition $rootNode): void ->arrayNode('graphql_playground') ->{class_exists(GraphQL::class) && class_exists(TwigBundle::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->end() + ->arrayNode('introspection') + ->canBeDisabled() + ->end() ->scalarNode('nesting_separator')->defaultValue('_')->info('The separator to use to filter nested fields.')->end() ->arrayNode('collection') ->addDefaultsIfNotSet() @@ -282,6 +286,10 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->end() ->arrayNode('api_keys') ->useAttributeAsKey('key') + ->validate() + ->ifTrue(static fn($v): bool => (bool) array_filter(array_keys($v), fn($item) => !preg_match('/^[a-zA-Z0-9._-]+$/', $item))) + ->thenInvalid('The api keys "key" is not valid according to the pattern enforced by OpenAPI 3.1 ^[a-zA-Z0-9._-]+$.') + ->end() ->prototype('array') ->children() ->scalarNode('name') diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 2236776fb39..1e3f4ae7ef1 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -176,11 +176,17 @@ + - api_platform.action.exception + api_platform.action.placeholder %kernel.debug% + + %api_platform.error_formats% + %api_platform.exception_to_status% + + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 8abdb8f9745..126995326d5 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -116,7 +116,7 @@ - + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 6318863fd24..112c4f6d252 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -152,7 +152,7 @@ - + diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index fb412236b11..440292ca597 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -5,7 +5,9 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - + + %api_platform.graphql.introspection.enabled% + diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 5db12dd6978..7b50428c6b8 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -46,6 +46,7 @@ + %kernel.debug% diff --git a/src/Symfony/Bundle/Resources/config/json_schema.xml b/src/Symfony/Bundle/Resources/config/json_schema.xml index 864e12900f2..4081591327e 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.xml +++ b/src/Symfony/Bundle/Resources/config/json_schema.xml @@ -16,7 +16,7 @@ - + null @@ -31,6 +31,11 @@ + + + + + diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Symfony/Bundle/Resources/config/jsonapi.xml index 2c4e41f3e8f..7e084dacc62 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Symfony/Bundle/Resources/config/jsonapi.xml @@ -65,6 +65,7 @@ + %kernel.debug% @@ -91,6 +92,11 @@ + + + + + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 3a08ed4f883..4e21c1d40c6 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -53,6 +53,7 @@ %api_platform.formats% %api_platform.patch_formats% + %api_platform.error_formats% diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml b/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml index df4e7f3a425..e677791228e 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource_name.xml @@ -27,5 +27,9 @@ %api_platform.resource_class_directories% + + %api_platform.class_name_resources% + + diff --git a/src/Symfony/Bundle/Resources/config/problem.xml b/src/Symfony/Bundle/Resources/config/problem.xml index 5c23f8ecfa0..c253f393dba 100644 --- a/src/Symfony/Bundle/Resources/config/problem.xml +++ b/src/Symfony/Bundle/Resources/config/problem.xml @@ -19,6 +19,7 @@ + %kernel.debug% diff --git a/src/Symfony/Bundle/Resources/config/state.xml b/src/Symfony/Bundle/Resources/config/state.xml index f0558f89ef0..1a87aab4573 100644 --- a/src/Symfony/Bundle/Resources/config/state.xml +++ b/src/Symfony/Bundle/Resources/config/state.xml @@ -53,6 +53,11 @@ + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index 4fa30e30a6e..b6b58b8a174 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -11,6 +11,7 @@ %api_platform.formats% + %api_platform.error_formats% @@ -48,6 +49,8 @@ + %api_platform.error_formats% + %kernel.debug% @@ -62,8 +65,6 @@ %api_platform.error_formats% %api_platform.exception_to_status% - - diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php index d09f4d005ca..c262d788fd5 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -44,6 +44,10 @@ public function __invoke(Request $request): Response { $openApi = $this->openApiFactory->__invoke(['base_url' => $request->getBaseUrl() ?: '/']); + foreach ($request->attributes->get('_api_exception_swagger_data') ?? [] as $key => $value) { + $request->attributes->set($key, $value); + } + $swaggerContext = [ 'formats' => $this->formats, 'title' => $openApi->getInfo()->getTitle(), diff --git a/src/Symfony/Bundle/Test/ApiTestCase.php b/src/Symfony/Bundle/Test/ApiTestCase.php index 9117b0e6025..c8fce9772ac 100644 --- a/src/Symfony/Bundle/Test/ApiTestCase.php +++ b/src/Symfony/Bundle/Test/ApiTestCase.php @@ -94,9 +94,17 @@ protected function findIriBy(string $resourceClass, array $criteria): ?string return null; } + return $this->getIriFromResource($item); + } + + /** + * Generate the IRI of a resource item. + */ + protected function getIriFromResource(object $resource): ?string + { /** @var IriConverterInterface $iriConverter */ - $iriConverter = $container->get('api_platform.iri_converter'); + $iriConverter = static::getContainer()->get('api_platform.iri_converter'); - return $iriConverter->getIriFromResource($item); + return $iriConverter->getIriFromResource($resource); } } diff --git a/src/Symfony/EventListener/AddFormatListener.php b/src/Symfony/EventListener/AddFormatListener.php index c2c2232a40b..82596629085 100644 --- a/src/Symfony/EventListener/AddFormatListener.php +++ b/src/Symfony/EventListener/AddFormatListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\EventListener; use ApiPlatform\Api\FormatMatcher; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Util\OperationRequestInitiatorTrait; use Negotiation\Negotiator; @@ -31,7 +32,7 @@ final class AddFormatListener { use OperationRequestInitiatorTrait; - public function __construct(private readonly Negotiator $negotiator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly array $formats = []) + public function __construct(private readonly Negotiator $negotiator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly array $formats = [], private readonly array $errorFormats = []) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } @@ -47,8 +48,7 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); - if (!( - $request->attributes->has('_api_resource_class') + if (!($request->attributes->has('_api_resource_class') || $request->attributes->getBoolean('_api_respond', false) || $request->attributes->getBoolean('_graphql', false) )) { @@ -64,7 +64,12 @@ public function onKernelRequest(RequestEvent $event): void $flattenedMimeTypes = $this->flattenMimeTypes($formats); $mimeTypes = array_keys($flattenedMimeTypes); } elseif (!isset($formats[$routeFormat])) { - throw new NotFoundHttpException(sprintf('Format "%s" is not supported', $routeFormat)); + if (!$request->attributes->get('data') instanceof \Exception) { + throw new NotFoundHttpException(sprintf('Format "%s" is not supported', $routeFormat)); + } + $this->setRequestErrorFormat($operation, $request); + + return; } else { $mimeTypes = Request::getMimeTypes($routeFormat); $flattenedMimeTypes = $this->flattenMimeTypes([$routeFormat => $mimeTypes]); @@ -75,7 +80,13 @@ public function onKernelRequest(RequestEvent $event): void $accept = $request->headers->get('Accept'); if (null !== $accept) { if (null === $mediaType = $this->negotiator->getBest($accept, $mimeTypes)) { - throw $this->getNotAcceptableHttpException($accept, $flattenedMimeTypes); + if (!$request->attributes->get('data') instanceof \Exception) { + throw $this->getNotAcceptableHttpException($accept, $flattenedMimeTypes); + } + + $this->setRequestErrorFormat($operation, $request); + + return; } $formatMatcher = new FormatMatcher($formats); @@ -93,6 +104,12 @@ public function onKernelRequest(RequestEvent $event): void return; } + if ($request->attributes->get('data') instanceof \Exception) { + $this->setRequestErrorFormat($operation, $request); + + return; + } + throw $this->getNotAcceptableHttpException($mimeType, $flattenedMimeTypes); } @@ -142,4 +159,25 @@ private function getNotAcceptableHttpException(string $accept, array $mimeTypes) implode('", "', array_keys($mimeTypes)) )); } + + public function setRequestErrorFormat(?HttpOperation $operation, Request $request): void + { + $errorResourceFormats = array_merge($operation?->getOutputFormats() ?? [], $operation?->getFormats() ?? [], $this->errorFormats); + + $flattened = $this->flattenMimeTypes($errorResourceFormats); + if ($flattened[$accept = $request->headers->get('Accept')] ?? false) { + $request->setRequestFormat($flattened[$accept]); + + return; + } + + if (isset($errorResourceFormats['jsonproblem'])) { + $request->setRequestFormat('jsonproblem'); + $request->setFormat('jsonproblem', $errorResourceFormats['jsonproblem']); + + return; + } + + $request->setRequestFormat(array_key_first($errorResourceFormats)); + } } diff --git a/src/Symfony/EventListener/AddHeadersListener.php b/src/Symfony/EventListener/AddHeadersListener.php new file mode 100644 index 00000000000..3ddd57f1c2f --- /dev/null +++ b/src/Symfony/EventListener/AddHeadersListener.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Util\OperationRequestInitiatorTrait; +use ApiPlatform\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\ResponseEvent; + +/** + * Configures cache HTTP headers for the current response. + * + * @author Kévin Dunglas + */ +final class AddHeadersListener +{ + use OperationRequestInitiatorTrait; + + public function __construct(private readonly bool $etag = false, private readonly ?int $maxAge = null, private readonly ?int $sharedMaxAge = null, private readonly ?array $vary = null, private readonly ?bool $public = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly ?int $staleWhileRevalidate = null, private readonly ?int $staleIfError = null) + { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + + public function onKernelResponse(ResponseEvent $event): void + { + $request = $event->getRequest(); + if (!$request->isMethodCacheable()) { + return; + } + + $attributes = RequestAttributesExtractor::extractAttributes($request); + if (\count($attributes) < 1) { + return; + } + + $response = $event->getResponse(); + + if (!$response->getContent() || !$response->isSuccessful()) { + return; + } + + $operation = $this->initializeOperation($request); + $resourceCacheHeaders = $attributes['cache_headers'] ?? $operation?->getCacheHeaders() ?? []; + + if ($this->etag && !$response->getEtag()) { + $response->setEtag(md5((string) $response->getContent())); + } + + if (null !== ($maxAge = $resourceCacheHeaders['max_age'] ?? $this->maxAge) && !$response->headers->hasCacheControlDirective('max-age')) { + $response->setMaxAge($maxAge); + } + + $vary = $resourceCacheHeaders['vary'] ?? $this->vary; + if (null !== $vary) { + $response->setVary(array_diff($vary, $response->getVary()), false); + } + + // if the public-property is defined and not yet set; apply it to the response + $public = ($resourceCacheHeaders['public'] ?? $this->public); + if (null !== $public && !$response->headers->hasCacheControlDirective('public')) { + $public ? $response->setPublic() : $response->setPrivate(); + } + + // Cache-Control "s-maxage" is only relevant is resource is not marked as "private" + if (false !== $public && null !== ($sharedMaxAge = $resourceCacheHeaders['shared_max_age'] ?? $this->sharedMaxAge) && !$response->headers->hasCacheControlDirective('s-maxage')) { + $response->setSharedMaxAge($sharedMaxAge); + } + + if (null !== ($staleWhileRevalidate = $resourceCacheHeaders['stale_while_revalidate'] ?? $this->staleWhileRevalidate) && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) { + $response->headers->addCacheControlDirective('stale-while-revalidate', (string) $staleWhileRevalidate); + } + + if (null !== ($staleIfError = $resourceCacheHeaders['stale_if_error'] ?? $this->staleIfError) && !$response->headers->hasCacheControlDirective('stale-if-error')) { + $response->headers->addCacheControlDirective('stale-if-error', (string) $staleIfError); + } + } +} diff --git a/src/Symfony/EventListener/AddTagsListener.php b/src/Symfony/EventListener/AddTagsListener.php new file mode 100644 index 00000000000..ffcfb9d1046 --- /dev/null +++ b/src/Symfony/EventListener/AddTagsListener.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\Symfony\EventListener; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\UriVariablesResolverTrait; +use ApiPlatform\Util\OperationRequestInitiatorTrait; +use ApiPlatform\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\ResponseEvent; + +/** + * Sets the list of resources' IRIs included in this response in the configured cache tag HTTP header and/or "xkey" HTTP headers. + * + * By default the "Cache-Tags" HTTP header is used because it is supported by CloudFlare. + * + * @see https://developers.cloudflare.com/cache/how-to/purge-cache#add-cache-tag-http-response-headers + * + * The "xkey" is used because it is supported by Varnish. + * @see https://docs.varnish-software.com/varnish-cache-plus/vmods/ykey/ + * + * @author Kévin Dunglas + */ +final class AddTagsListener +{ + use OperationRequestInitiatorTrait; + use UriVariablesResolverTrait; + + public function __construct(private readonly IriConverterInterface $iriConverter, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly ?PurgerInterface $purger = null) + { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + + /** + * Adds the configured HTTP cache tag and "xkey" headers. + */ + public function onKernelResponse(ResponseEvent $event): void + { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + $response = $event->getResponse(); + + if ( + !$request->isMethodCacheable() + || !$response->isCacheable() + || (!$attributes = RequestAttributesExtractor::extractAttributes($request)) + ) { + return; + } + + $resources = $request->attributes->get('_resources'); + if ($operation instanceof CollectionOperationInterface) { + // Allows to purge collections + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $attributes['resource_class']); + $iri = $this->iriConverter->getIriFromResource($attributes['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); + + $resources[$iri] = $iri; + } + + if (!$resources) { + return; + } + + if (!$this->purger) { + $response->headers->set('Cache-Tags', implode(',', $resources)); + + return; + } + + $headers = $this->purger->getResponseHeaders($resources); + + foreach ($headers as $key => $value) { + $response->headers->set($key, $value); + } + } +} diff --git a/src/Symfony/EventListener/DeserializeListener.php b/src/Symfony/EventListener/DeserializeListener.php index a56f8475bcf..3cc47640277 100644 --- a/src/Symfony/EventListener/DeserializeListener.php +++ b/src/Symfony/EventListener/DeserializeListener.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Symfony\EventListener; use ApiPlatform\Api\FormatMatcher; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Symfony\Validator\Exception\ValidationException; @@ -88,9 +87,9 @@ public function onKernelRequest(RequestEvent $event): void if ( null !== $data && ( - HttpOperation::METHOD_POST === $method - || HttpOperation::METHOD_PATCH === $method - || (HttpOperation::METHOD_PUT === $method && !($operation->getExtraProperties()['standard_put'] ?? false)) + 'POST' === $method + || 'PATCH' === $method + || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? false)) ) ) { $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; diff --git a/src/Symfony/EventListener/ErrorListener.php b/src/Symfony/EventListener/ErrorListener.php index 592190405bb..84b67338bf2 100644 --- a/src/Symfony/EventListener/ErrorListener.php +++ b/src/Symfony/EventListener/ErrorListener.php @@ -13,9 +13,22 @@ namespace ApiPlatform\Symfony\EventListener; -use ApiPlatform\Action\ExceptionAction; +use ApiPlatform\Api\IdentifiersExtractorInterface; +use ApiPlatform\ApiResource\Error; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Util\ErrorFormatGuesser; +use ApiPlatform\Util\OperationRequestInitiatorTrait; +use ApiPlatform\Util\RequestAttributesExtractor; +use ApiPlatform\Validator\Exception\ValidationException; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\EventListener\ErrorListener as SymfonyErrorListener; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; /** * This error listener extends the Symfony one in order to add @@ -24,14 +37,151 @@ */ final class ErrorListener extends SymfonyErrorListener { + use OperationRequestInitiatorTrait; + + public function __construct( + object|array|string|null $controller, + LoggerInterface $logger = null, + bool $debug = false, + array $exceptionsMapping = [], + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly array $errorFormats = [], + private readonly array $exceptionToStatus = [], + private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, + private readonly ?ResourceClassResolverInterface $resourceClassResolver = null + ) { + parent::__construct($controller, $logger, $debug, $exceptionsMapping); + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + protected function duplicateRequest(\Throwable $exception, Request $request): Request { $dup = parent::duplicateRequest($exception, $request); - if ($request->attributes->has('_api_operation')) { - $dup->attributes->set('_api_operation', $request->attributes->get('_api_operation')); + $apiOperation = $this->initializeOperation($request); + + $resourceClass = $exception::class; + $format = ErrorFormatGuesser::guessErrorFormat($request, $this->errorFormats); + + if ($this->resourceClassResolver?->isResourceClass($exception::class)) { + $resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class); + + $operation = null; + foreach ($resourceCollection as $resource) { + foreach ($resource->getOperations() as $op) { + foreach ($op->getOutputFormats() as $key => $value) { + if ($key === $format['key']) { + $operation = $op; + break 3; + } + } + } + } + + // No operation found for the requested format, we take the first available + if (!$operation) { + $operation = $resourceCollection->getOperation(); + } + $errorResource = $exception; + } elseif ($this->resourceMetadataCollectionFactory) { + // Create a generic, rfc7807 compatible error according to the wanted format + /** @var HttpOperation $operation */ + $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format['key'] ?? null)); + $operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); + $errorResource = Error::createFromException($exception, $operation->getStatus()); + $resourceClass = Error::class; + } else { + $operation = new Get(name: '_api_errors_problem', class: Error::class, outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]); + $operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); + $errorResource = Error::createFromException($exception, $operation->getStatus()); + $resourceClass = Error::class; + } + + $identifiers = $this->identifiersExtractor?->getIdentifiersFromItem($errorResource, $operation) ?? []; + + $dup->attributes->set('_api_error', true); + $dup->attributes->set('_api_resource_class', $resourceClass); + $dup->attributes->set('_api_previous_operation', $apiOperation); + $dup->attributes->set('_api_operation', $operation); + $dup->attributes->set('_api_operation_name', $operation->getName()); + $dup->attributes->remove('exception'); + $dup->attributes->set('data', $errorResource); + // Once we get rid of the SwaggerUiAction we'll be able to do this properly + $dup->attributes->set('_api_exception_swagger_data', [ + '_route' => $request->attributes->get('_route'), + '_route_params' => $request->attributes->get('_route_params'), + '_api_resource_class' => $request->attributes->get('_api_resource_class'), + '_api_operation_name' => $request->attributes->get('_api_operation_name'), + ]); + + foreach ($identifiers as $name => $value) { + $dup->attributes->set($name, $value); } return $dup; } + + private function getOperationExceptionToStatus(Request $request): array + { + $attributes = RequestAttributesExtractor::extractAttributes($request); + + if ([] === $attributes) { + return []; + } + + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($attributes['resource_class']); + /** @var HttpOperation $operation */ + $operation = $resourceMetadataCollection->getOperation($attributes['operation_name'] ?? null); + $exceptionToStatus = [$operation->getExceptionToStatus() ?: []]; + + foreach ($resourceMetadataCollection as $resourceMetadata) { + /* @var ApiResource $resourceMetadata */ + $exceptionToStatus[] = $resourceMetadata->getExceptionToStatus() ?: []; + } + + return array_merge(...$exceptionToStatus); + } + + private function getStatusCode(?HttpOperation $apiOperation, Request $request, ?HttpOperation $errorOperation, \Throwable $exception): int + { + $exceptionToStatus = array_merge( + $this->exceptionToStatus, + $apiOperation ? $apiOperation->getExceptionToStatus() ?? [] : $this->getOperationExceptionToStatus($request), + $errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : [] + ); + + foreach ($exceptionToStatus as $class => $status) { + if (is_a($exception::class, $class, true)) { + return $status; + } + } + + if ($exception instanceof SymfonyHttpExceptionInterface) { + return $exception->getStatusCode(); + } + + if ($exception instanceof RequestExceptionInterface) { + return 400; + } + + if ($exception instanceof ValidationException) { + return 422; + } + + if ($status = $errorOperation?->getStatus()) { + return $status; + } + + return 500; + } + + private function getFormatOperation(string $format): ?string + { + return match ($format) { + 'jsonproblem' => '_api_errors_problem', + 'jsonld' => '_api_errors_hydra', + 'jsonapi' => '_api_errors_jsonapi', + default => null + }; + } } diff --git a/src/Symfony/EventListener/ReadListener.php b/src/Symfony/EventListener/ReadListener.php index e321229265d..ec7b1f0cd2d 100644 --- a/src/Symfony/EventListener/ReadListener.php +++ b/src/Symfony/EventListener/ReadListener.php @@ -16,7 +16,6 @@ use ApiPlatform\Api\UriVariablesConverterInterface; use ApiPlatform\Exception\InvalidIdentifierException; use ApiPlatform\Exception\InvalidUriVariableException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\SerializerContextBuilderInterface; @@ -90,7 +89,12 @@ public function onKernelRequest(RequestEvent $event): void $resourceClass = $operation->getClass() ?? $attributes['resource_class']; try { $uriVariables = $this->getOperationUriVariables($operation, $parameters, $resourceClass); - $data = $this->provider->provide($operation, $uriVariables, $context); + if ($request->attributes->get('_api_error', false)) { + $exception = $request->attributes->get('data'); + $data = $operation->getProvider() ? $this->provider->provide($operation, $uriVariables, $context + ['previous_data' => $exception]) : $exception; + } else { + $data = $this->provider->provide($operation, $uriVariables, $context); + } } catch (InvalidIdentifierException|InvalidUriVariableException $e) { throw new NotFoundHttpException('Invalid identifier value or configuration.', $e); } catch (ProviderNotFoundException $e) { @@ -99,9 +103,9 @@ public function onKernelRequest(RequestEvent $event): void if ( null === $data - && HttpOperation::METHOD_POST !== $operation->getMethod() + && 'POST' !== $operation->getMethod() && ( - HttpOperation::METHOD_PUT !== $operation->getMethod() + 'PUT' !== $operation->getMethod() || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) ) ) { diff --git a/src/Symfony/EventListener/RespondListener.php b/src/Symfony/EventListener/RespondListener.php index dcc370fb27e..b8b94b187cb 100644 --- a/src/Symfony/EventListener/RespondListener.php +++ b/src/Symfony/EventListener/RespondListener.php @@ -15,7 +15,7 @@ use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Util\OperationRequestInitiatorTrait; @@ -91,7 +91,7 @@ public function onKernelView(ViewEvent $event): void ) { $status = 301; $headers['Location'] = $this->iriConverter->getIriFromResource($request->attributes->get('data'), UrlGeneratorInterface::ABS_PATH, $operation); - } elseif (HttpOperation::METHOD_PUT === $method && !($attributes['previous_data'] ?? null) && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { + } elseif ('PUT' === $method && !($attributes['previous_data'] ?? null) && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { $status = Response::HTTP_CREATED; } @@ -100,11 +100,15 @@ public function onKernelView(ViewEvent $event): void if ($request->attributes->has('_api_write_item_iri')) { $headers['Content-Location'] = $request->attributes->get('_api_write_item_iri'); - if ((Response::HTTP_CREATED === $status || (300 <= $status && $status < 400)) && HttpOperation::METHOD_POST === $method) { + if ((Response::HTTP_CREATED === $status || (300 <= $status && $status < 400)) && 'POST' === $method) { $headers['Location'] = $request->attributes->get('_api_write_item_iri'); } } + if (($exception = $request->attributes->get('data')) instanceof HttpExceptionInterface) { + $headers = array_merge($headers, $exception->getHeaders()); + } + $event->setResponse(new Response( $controllerResult, $status, diff --git a/src/Symfony/EventListener/SerializeListener.php b/src/Symfony/EventListener/SerializeListener.php index cf7d9a271a7..7bf4bf04527 100644 --- a/src/Symfony/EventListener/SerializeListener.php +++ b/src/Symfony/EventListener/SerializeListener.php @@ -18,8 +18,10 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\ResourceList; use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Util\ErrorFormatGuesser; use ApiPlatform\Util\OperationRequestInitiatorTrait; use ApiPlatform\Util\RequestAttributesExtractor; +use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ViewEvent; @@ -40,8 +42,13 @@ final class SerializeListener public const OPERATION_ATTRIBUTE_KEY = 'serialize'; - public function __construct(private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null) - { + public function __construct( + private readonly SerializerInterface $serializer, + private readonly SerializerContextBuilderInterface $serializerContextBuilder, + ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + private readonly array $errorFormats = [], + private readonly bool $debug = false, + ) { $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; } @@ -81,6 +88,29 @@ public function onKernelView(ViewEvent $event): void return; } + if ($controllerResult instanceof ValidationException) { + $format = ErrorFormatGuesser::guessErrorFormat($request, $this->errorFormats); + $previousOperation = $request->attributes->get('_api_previous_operation'); + if (!($previousOperation?->getExtraProperties()['rfc_7807_compliant_errors'] ?? false)) { + $context['groups'] = ['legacy_'.$format['key']]; + $context['force_iri_generation'] = false; + } + } + + if ($request->get('_api_error', false)) { + $context['skip_deprecated_exception_normalizers'] = true; + + if ($this->debug) { + $groups = $context['groups'] ?? []; + if (!\is_array($groups)) { + $groups = [$groups]; + } + + $groups[] = 'trace'; + $context['groups'] = $groups; + } + } + if ($included = $request->attributes->get('_api_included')) { $context['api_included'] = $included; } diff --git a/src/Symfony/LICENSE b/src/Symfony/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/Symfony/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/README.md b/src/Symfony/README.md new file mode 100644 index 00000000000..b7d6e24a293 --- /dev/null +++ b/src/Symfony/README.md @@ -0,0 +1,3 @@ +# API Platform - Symfony + + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index a613c89eb0f..e7f08dfdf79 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Symfony\Routing; use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use Symfony\Component\Config\FileLocator; @@ -81,7 +80,7 @@ public function load(mixed $data, string $type = null): RouteCollection } if ($controller = $operation->getController()) { - $controllerId = explode('::', $controller)[0]; + $controllerId = explode('::', $controller, 2)[0]; if (!$this->container->has($controllerId)) { throw new RuntimeException(sprintf('Operation "%s" is defining an unknown service as controller "%s". Make sure it is properly registered in the dependency injection container.', $operationName, $controllerId)); } @@ -100,7 +99,7 @@ public function load(mixed $data, string $type = null): RouteCollection $operation->getOptions() ?? [], $operation->getHost() ?? '', $operation->getSchemes() ?? [], - [$operation->getMethod() ?? HttpOperation::METHOD_GET], + [$operation->getMethod() ?? 'GET'], $operation->getCondition() ?? '' ); diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 91a580c3aee..4c17fb917fa 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -30,11 +30,11 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Util\AttributesExtractor; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\UriVariablesResolverTrait; -use ApiPlatform\Util\AttributesExtractor; use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingExceptionInterface; use Symfony\Component\Routing\RouterInterface; @@ -138,7 +138,7 @@ public function getIriFromResource(object|string $resource, int $referenceType = // In symfony the operation name is the route name, try to find one if none provided if ( !$operation->getName() - || ($operation instanceof HttpOperation && HttpOperation::METHOD_POST === $operation->getMethod()) + || ($operation instanceof HttpOperation && 'POST' === $operation->getMethod()) ) { $forceCollection = $operation instanceof CollectionOperationInterface; try { diff --git a/src/Symfony/Util/RequestAttributesExtractor.php b/src/Symfony/Util/RequestAttributesExtractor.php new file mode 100644 index 00000000000..74d549e505b --- /dev/null +++ b/src/Symfony/Util/RequestAttributesExtractor.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Util; + +use ApiPlatform\Metadata\Util\AttributesExtractor; +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts data used by the library form a Request instance. + * + * @internal + * + * @author Kévin Dunglas + */ +final class RequestAttributesExtractor +{ + private function __construct() + { + } + + /** + * Extracts resource class, operation name and format request attributes. Returns an empty array if the request does + * not contain required attributes. + */ + public static function extractAttributes(Request $request): array + { + return AttributesExtractor::extractAttributes($request->attributes->all()); + } +} diff --git a/src/Symfony/Validator/EventListener/ValidationExceptionListener.php b/src/Symfony/Validator/EventListener/ValidationExceptionListener.php index 52e8be507e5..99d913fddff 100644 --- a/src/Symfony/Validator/EventListener/ValidationExceptionListener.php +++ b/src/Symfony/Validator/EventListener/ValidationExceptionListener.php @@ -23,6 +23,7 @@ /** * Handles validation errors. + * todo remove this class. * * @author Kévin Dunglas */ @@ -37,6 +38,7 @@ public function __construct(private readonly SerializerInterface $serializer, pr */ public function onKernelException(ExceptionEvent $event): void { + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated and will be removed in 4.x.', __CLASS__)); $exception = $event->getThrowable(); if (!$exception instanceof ConstraintViolationListAwareExceptionInterface && !$exception instanceof FilterValidationException) { return; diff --git a/src/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php b/src/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php index 1eafea9c9a1..0d3dc6dd7e8 100644 --- a/src/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php +++ b/src/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Symfony\Validator\Exception; -use ApiPlatform\Exception\ExceptionInterface; +use ApiPlatform\Metadata\Exception\ExceptionInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; /** diff --git a/src/Symfony/Validator/Exception/ValidationException.php b/src/Symfony/Validator/Exception/ValidationException.php index 520b422b160..5f582d4e31a 100644 --- a/src/Symfony/Validator/Exception/ValidationException.php +++ b/src/Symfony/Validator/Exception/ValidationException.php @@ -13,7 +13,13 @@ namespace ApiPlatform\Symfony\Validator\Exception; +use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Validator\Exception\ValidationException as BaseValidationException; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Validator\ConstraintViolationListInterface; /** @@ -21,7 +27,17 @@ * * @author Kévin Dunglas */ -final class ValidationException extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface, \Stringable +#[ErrorResource(uriTemplate: '/validation_errors/{id}', provider: 'api_platform.state_provider.default_error', + status: 422, + uriVariables: ['id'], + shortName: 'ConstraintViolationList', + operations: [ + new Get(name: '_api_validation_errors_hydra', outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => 'jsonld', 'skip_null_values' => true]), + new Get(name: '_api_validation_errors_problem', outputFormats: ['jsonproblem' => ['application/problem+json']], normalizationContext: ['groups' => 'json', 'skip_null_values' => true]), + new Get(name: '_api_validation_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => 'jsonapi', 'skip_null_values' => true], provider: 'api_platform.json_api.state_provider.default_error'), + ] +)] +final class ValidationException extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface, \Stringable, ProblemExceptionInterface { public function __construct(private readonly ConstraintViolationListInterface $constraintViolationList, string $message = '', int $code = 0, \Throwable $previous = null, string $errorTitle = null) { @@ -33,6 +49,22 @@ public function getConstraintViolationList(): ConstraintViolationListInterface return $this->constraintViolationList; } + public function getId(): string + { + $ids = []; + foreach ($this->getConstraintViolationList() as $violation) { + $ids[] = $violation->getCode(); + } + + $id = 1 < \count($ids) ? CompositeIdentifierParser::stringify(identifiers: $ids) : ($ids[0] ?? null); + + if (!$id) { + return spl_object_hash($this); + } + + return $id; + } + public function __toString(): string { $message = ''; @@ -49,4 +81,68 @@ public function __toString(): string return $message; } + + #[SerializedName('hydra:title')] + #[Groups(['jsonld', 'legacy_jsonld'])] + public function getHydraTitle(): string + { + return $this->errorTitle ?? 'An error occurred'; + } + + #[SerializedName('hydra:description')] + #[Groups(['jsonld', 'legacy_jsonld'])] + public function getHydraDescription(): string + { + return $this->__toString(); + } + + #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + public function getType(): string + { + return '/validation_errors/'.$this->getId(); + } + + #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + public function getTitle(): ?string + { + return $this->errorTitle ?? 'An error occurred'; + } + + #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + public function getDetail(): ?string + { + return $this->__toString(); + } + + #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + public function getStatus(): ?int + { + return 422; + } + + #[Groups(['jsonld', 'json'])] + public function getInstance(): ?string + { + return null; + } + + #[SerializedName('violations')] + #[Groups(['json', 'jsonld', 'legacy_jsonld', 'legacy_jsonproblem'])] + public function getViolations(): iterable + { + foreach ($this->getConstraintViolationList() as $violation) { + $propertyPath = $violation->getPropertyPath(); + $violationData = [ + 'propertyPath' => $propertyPath, + 'message' => $violation->getMessage(), + 'code' => $violation->getCode(), + ]; + + if ($hint = $violation->getParameters()['hint'] ?? false) { + $violationData['hint'] = $hint; + } + + yield $violationData; + } + } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php index e100bd98bc1..a0d22e66f53 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php @@ -52,9 +52,15 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a $restriction['type'] = 'array'; - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type) { - $restriction['items'] = ['type' => Type::BUILTIN_TYPE_STRING === $type->getBuiltinType() ? 'string' : 'number', 'enum' => $choices]; + $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; + $types = array_unique(array_map(fn (Type $type) => Type::BUILTIN_TYPE_STRING === $type->getBuiltinType() ? 'string' : 'number', $builtInTypes)); + + if ($count = \count($types)) { + if (1 === $count) { + $types = $types[0]; + } + + $restriction['items'] = ['type' => $types, 'enum' => $choices]; } if (null !== $constraint->min) { @@ -73,6 +79,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof Choice && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof Choice && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php index 26d6cf3faf9..4ec1568b749 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php @@ -40,6 +40,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof GreaterThanOrEqual && is_numeric($constraint->value) && ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof GreaterThanOrEqual && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php index e503704aef0..4fc357d390a 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -41,6 +41,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof GreaterThan && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof GreaterThan && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php index 9a9c0b871e1..c3e6095aa71 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php @@ -50,6 +50,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof Length && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && Type::BUILTIN_TYPE_STRING === $type->getBuiltinType(); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof Length && \count($types) && \in_array(Type::BUILTIN_TYPE_STRING, $types, true); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php index f2ef047a5a1..2882e7f7faf 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php @@ -40,6 +40,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof LessThanOrEqual && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof LessThanOrEqual && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php index f8d4e484897..c7354f9af80 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -41,6 +41,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof LessThan && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof LessThan && is_numeric($constraint->value) && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php index cab5903b601..642d5aacc04 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php @@ -48,6 +48,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - return $constraint instanceof Range && null !== ($type = $propertyMetadata->getBuiltinTypes()[0] ?? null) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + $types = array_map(fn (Type $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); + + return $constraint instanceof Range && \count($types) && array_intersect($types, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]); } } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json new file mode 100644 index 00000000000..60c721df740 --- /dev/null +++ b/src/Symfony/composer.json @@ -0,0 +1,73 @@ +{ + "name": "api-platform/symfony", + "description": "Symfony API Platform integration", + "type": "library", + "keywords": [ + "API" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", + "symfony/property-info": "^6.1", + "symfony/serializer": "^6.1", + "symfony/security-core": "^6.1" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1", + "symfony/routing": "^6.1", + "symfony/validator": "^6.1", + "symfony/mercure-bundle": "*", + "webonyx/graphql-php": "^14.0 || ^15.0" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Symfony\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + }, + { + "type": "path", + "url": "../State" + } + ] +} diff --git a/src/Symfony/phpunit.xml.dist b/src/Symfony/phpunit.xml.dist new file mode 100644 index 00000000000..c04b1d7a3ee --- /dev/null +++ b/src/Symfony/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Util/Inflector.php b/src/Util/Inflector.php index 81f5bf6dc84..5dc86a07bb8 100644 --- a/src/Util/Inflector.php +++ b/src/Util/Inflector.php @@ -13,19 +13,17 @@ namespace ApiPlatform\Util; -use Doctrine\Inflector\Inflector as InflectorObject; +use Doctrine\Inflector\Inflector as LegacyInflector; use Doctrine\Inflector\InflectorFactory; /** - * Facade for Doctrine Inflector. - * * @internal */ final class Inflector { - private static ?InflectorObject $instance = null; + private static ?LegacyInflector $instance = null; - private static function getInstance(): InflectorObject + private static function getInstance(): LegacyInflector { return self::$instance ?? self::$instance = InflectorFactory::create()->build(); diff --git a/src/Util/RequestAttributesExtractor.php b/src/Util/RequestAttributesExtractor.php index 8565e922e92..2d180f6742e 100644 --- a/src/Util/RequestAttributesExtractor.php +++ b/src/Util/RequestAttributesExtractor.php @@ -18,6 +18,8 @@ /** * Extracts data used by the library form a Request instance. * + * @deprecated use \ApiPlatform\Symfony\Util\RequestAttributesExtractor although it should've been internal + * * @author Kévin Dunglas */ final class RequestAttributesExtractor diff --git a/src/Validator/.gitignore b/src/Validator/.gitignore new file mode 100644 index 00000000000..eb0a8e7b262 --- /dev/null +++ b/src/Validator/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index f781c00b335..c710cfee867 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Validator\Exception; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; /** * Thrown when a validation error occurs. diff --git a/src/Validator/LICENSE b/src/Validator/LICENSE new file mode 100644 index 00000000000..1ca98eeb824 --- /dev/null +++ b/src/Validator/LICENSE @@ -0,0 +1,21 @@ +The MIT license + +Copyright (c) 2015-present Kévin Dunglas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Validator/README.md b/src/Validator/README.md new file mode 100644 index 00000000000..61b275cec98 --- /dev/null +++ b/src/Validator/README.md @@ -0,0 +1,3 @@ +# API Platform - Validator + +Validator component from API Platform diff --git a/src/Validator/composer.json b/src/Validator/composer.json new file mode 100644 index 00000000000..fe9039be439 --- /dev/null +++ b/src/Validator/composer.json @@ -0,0 +1,59 @@ +{ + "name": "api-platform/validator", + "description": "API Platform validator component", + "type": "library", + "keywords": [ + "Validator", + "API" + ], + "homepage": "https://api-platform.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + }, + { + "name": "API Platform Community", + "homepage": "https://api-platform.com/community/contributors" + } + ], + "require": { + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1" + }, + "require-dev": { + "phpspec/prophecy-phpunit": "^2.0", + "symfony/phpunit-bridge": "^6.1" + }, + "autoload": { + "psr-4": { + "ApiPlatform\\Validator\\": "" + } + }, + "config": { + "preferred-install": { + "*": "dist" + }, + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.2.x-dev" + }, + "symfony": { + "require": "^6.1" + } + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + } + ] +} diff --git a/src/deprecation.php b/src/deprecation.php new file mode 100644 index 00000000000..2b6daa5a99c --- /dev/null +++ b/src/deprecation.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); + +$deprecatedClassesWithAliases = [ + \ApiPlatform\HttpCache\EventListener\AddHeadersListener::class => \ApiPlatform\Symfony\EventListener\AddHeadersListener::class, + \ApiPlatform\HttpCache\EventListener\AddTagsListener::class => \ApiPlatform\Symfony\EventListener\AddTagsListener::class, +]; + +spl_autoload_register(function ($className) use ($deprecatedClassesWithAliases): void { + if (isset($deprecatedClassesWithAliases[$className])) { + trigger_deprecation('api-platform/core', '4.0', sprintf('The class %s is deprecated, use %s instead.', $className, $deprecatedClassesWithAliases[$className])); + + class_alias($deprecatedClassesWithAliases[$className], $className); + + return; + } +}); diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index fa4facf60b4..f75e751b302 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -128,10 +128,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime; @@ -2145,20 +2143,6 @@ public function thereIsAResourceUsingEntityClassAndDateTime(): void $this->manager->flush(); } - /** - * @Given there is a dummy entity with a sub entity with id :strId and name :name - */ - public function thereIsADummyWithSubEntity(string $strId, string $name): void - { - $subEntity = new DummySubEntity($strId, $name); - $mainEntity = new DummyWithSubEntity(); - $mainEntity->setSubEntity($subEntity); - $mainEntity->setName('main'); - $this->manager->persist($subEntity); - $this->manager->persist($mainEntity); - $this->manager->flush(); - } - private function isOrm(): bool { return null !== $this->schemaTool; diff --git a/tests/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php b/tests/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php index cc84b194ec8..fbbce89f3ed 100644 --- a/tests/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php +++ b/tests/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php @@ -13,12 +13,12 @@ namespace ApiPlatform\Tests\Doctrine\Orm\Extension; -use ApiPlatform\Api\ResourceClassResolver; use ApiPlatform\Doctrine\Orm\Extension\FilterEagerLoadingExtension; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Metadata\ResourceClassResolver; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; diff --git a/tests/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactoryTest.php b/tests/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactoryTest.php index c3363404957..e7968e40653 100644 --- a/tests/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactoryTest.php +++ b/tests/Doctrine/Orm/Metadata/Resource/DoctrineOrmLinkFactoryTest.php @@ -15,10 +15,9 @@ use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmLinkFactory; -use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Metadata; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; @@ -77,22 +76,22 @@ public function testCreateLinksFromRelations(): void class LinkFactoryStub implements LinkFactoryInterface, PropertyLinkFactoryInterface { - public function createLinkFromProperty(ApiResource|Operation $operation, string $property): Link + public function createLinkFromProperty(Metadata $operation, string $property): Link { return new Link(); } - public function createLinksFromIdentifiers(ApiResource|Operation $operation): array + public function createLinksFromIdentifiers(Metadata $operation): array { return []; } - public function createLinksFromRelations(ApiResource|Operation $operation): array + public function createLinksFromRelations(Metadata $operation): array { return []; } - public function createLinksFromAttributes(ApiResource|Operation $operation): array + public function createLinksFromAttributes(Metadata $operation): array { return []; } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/ActivableInterface.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/ActivableInterface.php new file mode 100644 index 00000000000..fe6e41a1c32 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/ActivableInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +interface ActivableInterface +{ +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/Author.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Author.php new file mode 100644 index 00000000000..d2aaa1ec3a5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Author.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\AuthorItemProvider; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/issue-5452/authors/{id}{._format}', provider: AuthorItemProvider::class), + ] +)] +class Author implements ActivableInterface, TimestampableInterface +{ + public function __construct( + #[ApiProperty(identifier: true)] + public readonly string|int $id, + public readonly string $name + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/Book.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Book.php new file mode 100644 index 00000000000..2abd3f22068 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Book.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\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\BookCollectionProvider; + +#[GetCollection(uriTemplate: '/issue-5452/books{._format}', provider: BookCollectionProvider::class)] +#[Post(uriTemplate: '/issue-5452/books{._format}')] +class Book +{ + // union types + public string|int|null $number = null; + + // simple types + public ?string $isbn = null; + + // intersect types without specific typehint (throw an error: AbstractItemNormalizer line 872) + public ActivableInterface&TimestampableInterface $library; + + /** + * @var Author + */ + // intersect types with PHPDoc + public ActivableInterface&TimestampableInterface $author; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/Library.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Library.php new file mode 100644 index 00000000000..0a7ad0a54f7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/Library.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\LibraryItemProvider; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/issue-5452/libraries/{id}{._format}', provider: LibraryItemProvider::class), + ] +)] +class Library implements ActivableInterface, TimestampableInterface +{ + public function __construct( + #[ApiProperty(identifier: true)] + public readonly string|int $id, + public readonly string $name + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5452/TimestampableInterface.php b/tests/Fixtures/TestBundle/ApiResource/Issue5452/TimestampableInterface.php new file mode 100644 index 00000000000..b52501b4293 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5452/TimestampableInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452; + +interface TimestampableInterface +{ +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5605/MainResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue5605/MainResource.php deleted file mode 100644 index d92d86e961e..00000000000 --- a/tests/Fixtures/TestBundle/ApiResource/Issue5605/MainResource.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605; - -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Metadata\ApiFilter; -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\MainResourceProvider; - -#[ApiResource( - operations : [ - new Get(uriTemplate: '/dummy_with_subresource/{id}', uriVariables: ['id']), - new GetCollection(uriTemplate: '/dummy_with_subresource'), - ], - provider : MainResourceProvider::class, - stateOptions: new Options(entityClass: DummyWithSubEntity::class) -)] -#[ApiFilter(SearchFilter::class, properties: ['subEntity'])] -class MainResource -{ - #[ApiProperty(identifier: true)] - public int $id; - public string $name; - public SubResource $subResource; -} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5605/SubResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue5605/SubResource.php deleted file mode 100644 index 9ad526cbb5b..00000000000 --- a/tests/Fixtures/TestBundle/ApiResource/Issue5605/SubResource.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605; - -use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Get; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\SubResourceProvider; - -#[ApiResource( - operations : [ - new Get( - uriTemplate: '/dummy_subresource/{strId}', - uriVariables: ['strId'] - ), - ], - provider: SubResourceProvider::class, - stateOptions: new Options(entityClass: DummySubEntity::class) -)] -class SubResource -{ - #[ApiProperty(identifier: true)] - public string $strId; - - public string $name; -} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5736/Alpha.php b/tests/Fixtures/TestBundle/ApiResource/Issue5736/Alpha.php new file mode 100644 index 00000000000..195b8085a07 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5736/Alpha.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5736; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[Get( + provider: [Alpha::class, 'provide'], +)] +final class Alpha +{ + public function __construct(#[ApiProperty(identifier: true)] public int $alphaId) + { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self(alphaId: $uriVariables['alphaId']); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5736/Beta.php b/tests/Fixtures/TestBundle/ApiResource/Issue5736/Beta.php new file mode 100644 index 00000000000..1644756d192 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5736/Beta.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5736; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; + +#[Patch( + processor: [Beta::class, 'process'], + provider: [Beta::class, 'provide'], +)] +final class Beta +{ + public function __construct(#[ApiProperty(identifier: true)] public int $betaId, public ?Alpha $alpha = null) + { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self(betaId: $uriVariables['betaId']); + } + + public static function process($body) + { + return $body; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index e63b816e68d..96700070608 100644 --- a/tests/Fixtures/TestBundle/Document/Dummy.php +++ b/tests/Fixtures/TestBundle/Document/Dummy.php @@ -28,7 +28,7 @@ * @author Kévin Dunglas * @author Alexandre Delplace */ -#[ApiResource(extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]], 'standard_put' => false], filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'])] +#[ApiResource(extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]], 'standard_put' => false, 'rfc_7807_compliant_errors' => false], filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.mongodb.boolean', 'my_dummy.mongodb.date', 'my_dummy.mongodb.exists', 'my_dummy.mongodb.numeric', 'my_dummy.mongodb.order', 'my_dummy.mongodb.range', 'my_dummy.mongodb.search', 'my_dummy.property'], operations: [new Get()])] #[ODM\Document] diff --git a/tests/Fixtures/TestBundle/Document/DummyCustomMutation.php b/tests/Fixtures/TestBundle/Document/DummyCustomMutation.php index 7634a095960..224b5799e7b 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCustomMutation.php +++ b/tests/Fixtures/TestBundle/Document/DummyCustomMutation.php @@ -28,23 +28,27 @@ new Mutation( name: 'sum', resolver: 'app.graphql.mutation_resolver.dummy_custom', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']] ), new Mutation( name: 'sumNotPersisted', resolver: 'app.graphql.mutation_resolver.dummy_custom_not_persisted', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']] ), new Mutation(name: 'sumNoWriteCustomResult', resolver: 'app.graphql.mutation_resolver.dummy_custom_no_write_custom_result', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']], write: false ), new Mutation(name: 'sumOnlyPersist', resolver: 'app.graphql.mutation_resolver.dummy_custom_only_persist_document', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']], read: false, diff --git a/tests/Fixtures/TestBundle/Document/DummyValidation.php b/tests/Fixtures/TestBundle/Document/DummyValidation.php index a0fa6b10033..be3f362e42d 100644 --- a/tests/Fixtures/TestBundle/Document/DummyValidation.php +++ b/tests/Fixtures/TestBundle/Document/DummyValidation.php @@ -22,8 +22,8 @@ #[ApiResource(operations: [ new GetCollection(), new Post(uriTemplate: 'dummy_validation{._format}'), - new Post(routeName: 'post_validation_groups', validationContext: ['groups' => ['a']]), - new Post(routeName: 'post_validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator']), + new Post(routeName: 'post_validation_groups', validationContext: ['groups' => ['a']], extraProperties: ['rfc_7807_compliant_errors' => false]), + new Post(routeName: 'post_validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator'], extraProperties: ['rfc_7807_compliant_errors' => false]), ] )] #[ODM\Document] diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index c6595ac7393..afd6c9a7c32 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -27,7 +27,7 @@ * * @author Kévin Dunglas */ -#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false])] +#[ApiResource(filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], extraProperties: ['standard_put' => false, 'rfc_7807_compliant_errors' => false])] #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy')], status: 200, filters: ['my_dummy.boolean', 'my_dummy.date', 'my_dummy.exists', 'my_dummy.numeric', 'my_dummy.order', 'my_dummy.range', 'my_dummy.search', 'my_dummy.property'], operations: [new Get()])] #[ORM\Entity] diff --git a/tests/Fixtures/TestBundle/Entity/DummyCustomMutation.php b/tests/Fixtures/TestBundle/Entity/DummyCustomMutation.php index dee21dc0412..4aa12998c6f 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCustomMutation.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCustomMutation.php @@ -28,23 +28,27 @@ new Mutation( name: 'sum', resolver: 'app.graphql.mutation_resolver.dummy_custom', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']] ), new Mutation( name: 'sumNotPersisted', resolver: 'app.graphql.mutation_resolver.dummy_custom_not_persisted', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']] ), new Mutation(name: 'sumNoWriteCustomResult', resolver: 'app.graphql.mutation_resolver.dummy_custom_no_write_custom_result', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']], write: false ), new Mutation(name: 'sumOnlyPersist', resolver: 'app.graphql.mutation_resolver.dummy_custom_only_persist', + extraArgs: ['id' => ['type' => 'ID!']], normalizationContext: ['groups' => ['result']], denormalizationContext: ['groups' => ['sum']], read: false, diff --git a/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php b/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php index f7cf6676c7c..3cd965dd05a 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php +++ b/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php @@ -26,9 +26,9 @@ #[ApiResource( exceptionToStatus: [NotFoundHttpException::class => 400], operations: [ - new Get(exceptionToStatus: [NotFoundException::class => 404]), - new Put(), - new GetCollection(), + new Get(uriTemplate: '/dummy_exception_to_statuses/{id}', exceptionToStatus: [NotFoundException::class => 404]), + new Put(uriTemplate: '/dummy_exception_to_statuses/{id}'), + new GetCollection(uriTemplate: '/dummy_exception_to_statuses'), ] )] #[ApiFilter(RequiredFilter::class)] diff --git a/tests/Fixtures/TestBundle/Entity/DummySubEntity.php b/tests/Fixtures/TestBundle/Entity/DummySubEntity.php deleted file mode 100644 index 1743843c299..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummySubEntity.php +++ /dev/null @@ -1,61 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; - -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -class DummySubEntity -{ - #[ORM\Id] - #[ORM\Column(type: 'string')] - private string $strId; - - #[ORM\Column] - private string $name; - - #[ORM\OneToOne(inversedBy: 'subEntity', cascade: ['persist'])] - private ?DummyWithSubEntity $mainEntity = null; - - public function __construct($strId, $name) - { - $this->strId = $strId; - $this->name = $name; - } - - public function getStrId(): string - { - return $this->strId; - } - - public function getMainEntity(): ?DummyWithSubEntity - { - return $this->mainEntity; - } - - public function setMainEntity(DummyWithSubEntity $mainEntity): void - { - $this->mainEntity = $mainEntity; - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyValidation.php b/tests/Fixtures/TestBundle/Entity/DummyValidation.php index 8500aa1556b..ba8e9cabfa0 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyValidation.php +++ b/tests/Fixtures/TestBundle/Entity/DummyValidation.php @@ -22,8 +22,8 @@ #[ApiResource(operations: [ new GetCollection(), new Post(uriTemplate: 'dummy_validation{._format}'), - new Post(routeName: 'post_validation_groups', validationContext: ['groups' => ['a']]), - new Post(routeName: 'post_validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator']), + new Post(routeName: 'post_validation_groups', validationContext: ['groups' => ['a']], extraProperties: ['rfc_7807_compliant_errors' => false]), + new Post(routeName: 'post_validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator'], extraProperties: ['rfc_7807_compliant_errors' => false]), ] )] #[ORM\Entity] diff --git a/tests/Fixtures/TestBundle/Entity/DummyWithCollectDenormalizationErrors.php b/tests/Fixtures/TestBundle/Entity/DummyWithCollectDenormalizationErrors.php index 5cc43ac349a..bf461b9e0c7 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyWithCollectDenormalizationErrors.php +++ b/tests/Fixtures/TestBundle/Entity/DummyWithCollectDenormalizationErrors.php @@ -23,7 +23,8 @@ operations: [ new Post(uriTemplate: 'dummy_collect_denormalization'), ], - collectDenormalizationErrors: true + collectDenormalizationErrors: true, + extraProperties: ['rfc_7807_compliant_errors' => false] )] #[ORM\Entity] class DummyWithCollectDenormalizationErrors diff --git a/tests/Fixtures/TestBundle/Entity/DummyWithSubEntity.php b/tests/Fixtures/TestBundle/Entity/DummyWithSubEntity.php deleted file mode 100644 index ebd9344c783..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyWithSubEntity.php +++ /dev/null @@ -1,60 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; - -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -class DummyWithSubEntity -{ - #[ORM\Id] - #[ORM\Column(type: 'integer')] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private int $id; - - #[ORM\Column] - private string $name; - - #[ORM\OneToOne(mappedBy: 'mainEntity', cascade: ['persist'], fetch: 'EAGER')] - private ?DummySubEntity $subEntity = null; - - public function getId(): int - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getSubEntity(): ?DummySubEntity - { - return $this->subEntity; - } - - public function setSubEntity(?DummySubEntity $subEntity): void - { - if (null !== $subEntity && $subEntity->getMainEntity() !== $this) { - $subEntity->setMainEntity($this); - } - - $this->subEntity = $subEntity; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummyWithPropertiesDependingOnThemselves.php b/tests/Fixtures/TestBundle/Entity/SecuredDummyWithPropertiesDependingOnThemselves.php index 6301b711c3b..f33129df6b9 100644 --- a/tests/Fixtures/TestBundle/Entity/SecuredDummyWithPropertiesDependingOnThemselves.php +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummyWithPropertiesDependingOnThemselves.php @@ -27,9 +27,9 @@ */ #[ApiResource( operations: [ - new Get(), - new Patch(inputFormats: ['json' => ['application/merge-patch+json'], 'jsonapi']), - new Post(security: 'is_granted("ROLE_ADMIN")'), + new Get(uriTemplate: '/secured_dummy_with_properties_depending_on_themselves/{id}'), + new Patch(uriTemplate: '/secured_dummy_with_properties_depending_on_themselves/{id}', inputFormats: ['json' => ['application/merge-patch+json'], 'jsonapi']), + new Post(uriTemplate: '/secured_dummy_with_properties_depending_on_themselves', security: 'is_granted("ROLE_ADMIN")'), ] )] #[ORM\Entity] diff --git a/tests/Fixtures/TestBundle/Metadata/Get.php b/tests/Fixtures/TestBundle/Metadata/Get.php index 1fa87c75366..62ec00f4030 100644 --- a/tests/Fixtures/TestBundle/Metadata/Get.php +++ b/tests/Fixtures/TestBundle/Metadata/Get.php @@ -23,6 +23,6 @@ final class Get extends HttpOperation */ public function __construct(...$args) { - parent::__construct(self::METHOD_GET, ...$args); + parent::__construct('GET', ...$args); } } diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml b/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml index ccb3155e8f4..9eecce5816f 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml @@ -46,6 +46,7 @@ resources: - graphQlOperations: null operations: ApiPlatform\Metadata\GetCollection: + uriTemplate: '/spoons' provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider - graphQlOperations: ~ operations: diff --git a/tests/Fixtures/TestBundle/State/Issue5452/AuthorItemProvider.php b/tests/Fixtures/TestBundle/State/Issue5452/AuthorItemProvider.php new file mode 100644 index 00000000000..5789f098701 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/Issue5452/AuthorItemProvider.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\Tests\Fixtures\TestBundle\State\Issue5452; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; + +class AuthorItemProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return new Author(1, 'John DOE'); + } +} diff --git a/tests/Fixtures/TestBundle/State/Issue5452/BookCollectionProvider.php b/tests/Fixtures/TestBundle/State/Issue5452/BookCollectionProvider.php new file mode 100644 index 00000000000..4a5a50269ca --- /dev/null +++ b/tests/Fixtures/TestBundle/State/Issue5452/BookCollectionProvider.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; + +class BookCollectionProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $book = new Book(); + $book->number = 1; + $book->isbn = '978-3-16-148410-0'; + $book->author = new Author(1, 'John DOE'); + + return [$book]; + } +} diff --git a/tests/Fixtures/TestBundle/State/Issue5452/LibraryItemProvider.php b/tests/Fixtures/TestBundle/State/Issue5452/LibraryItemProvider.php new file mode 100644 index 00000000000..f6fa257debf --- /dev/null +++ b/tests/Fixtures/TestBundle/State/Issue5452/LibraryItemProvider.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\Tests\Fixtures\TestBundle\State\Issue5452; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Library; + +class LibraryItemProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return new Library(1, 'Le Bâteau Livre'); + } +} diff --git a/tests/Fixtures/TestBundle/State/Issue5605/MainResourceProvider.php b/tests/Fixtures/TestBundle/State/Issue5605/MainResourceProvider.php deleted file mode 100644 index bfea5439774..00000000000 --- a/tests/Fixtures/TestBundle/State/Issue5605/MainResourceProvider.php +++ /dev/null @@ -1,59 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605; - -use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\MainResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\SubResource; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; - -class MainResourceProvider implements ProviderInterface -{ - public function __construct(private readonly ProviderInterface $itemProvider, private readonly ProviderInterface $collectionProvider) - { - } - - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - if ($operation instanceof Get) { - /** - * @var DummyWithSubEntity $entity - */ - $entity = $this->itemProvider->provide($operation, $uriVariables, $context); - - return $this->getResource($entity); - } - $resources = []; - $entities = $this->collectionProvider->provide($operation, $uriVariables, $context); - foreach ($entities as $entity) { - $resources[] = $this->getResource($entity); - } - - return $resources; - } - - protected function getResource(DummyWithSubEntity $entity): MainResource - { - $resource = new MainResource(); - $resource->name = $entity->getName(); - $resource->id = $entity->getId(); - $resource->subResource = new SubResource(); - $resource->subResource->name = $entity->getSubEntity()->getName(); - $resource->subResource->strId = $entity->getSubEntity()->getStrId(); - - return $resource; - } -} diff --git a/tests/Fixtures/TestBundle/State/Issue5605/SubResourceProvider.php b/tests/Fixtures/TestBundle/State/Issue5605/SubResourceProvider.php deleted file mode 100644 index 0054635f2f8..00000000000 --- a/tests/Fixtures/TestBundle/State/Issue5605/SubResourceProvider.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\SubResource; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; - -class SubResourceProvider implements ProviderInterface -{ - public function __construct(private readonly ProviderInterface $itemProvider) - { - } - - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - /** - * @var DummySubEntity $entity - */ - $entity = $this->itemProvider->provide($operation, $uriVariables, $context); - $resource = new SubResource(); - $resource->strId = $entity->getStrId(); - $resource->name = $entity->getName(); - - return $resource; - } -} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 2eab0ec1323..e745cdb0f7b 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -61,9 +61,11 @@ api_platform: http_cache: invalidation: enabled: true + keep_legacy_inflector: false defaults: extra_properties: standard_put: true + rfc_7807_compliant_errors: true normalization_context: skip_null_values: false pagination_client_enabled: true @@ -424,3 +426,14 @@ services: tags: - { name: 'serializer.normalizer' } + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\AuthorItemProvider: + tags: + - { name: 'api_platform.state_provider' } + + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\BookCollectionProvider: + tags: + - { name: 'api_platform.state_provider' } + + ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5452\LibraryItemProvider: + tags: + - { name: 'api_platform.state_provider' } diff --git a/tests/Fixtures/app/config/config_doctrine.yml b/tests/Fixtures/app/config/config_doctrine.yml index 7a00a296db0..8045ddaae50 100644 --- a/tests/Fixtures/app/config/config_doctrine.yml +++ b/tests/Fixtures/app/config/config_doctrine.yml @@ -120,18 +120,3 @@ services: arguments: $itemProvider: '@ApiPlatform\Doctrine\Orm\State\ItemProvider' $collectionProvider: '@ApiPlatform\Doctrine\Orm\State\CollectionProvider' - - ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\MainResourceProvider: - class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\MainResourceProvider' - tags: - - name: 'api_platform.state_provider' - arguments: - $itemProvider: '@ApiPlatform\Doctrine\Orm\State\ItemProvider' - $collectionProvider: '@ApiPlatform\Doctrine\Orm\State\CollectionProvider' - - ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\SubResourceProvider: - class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\Issue5605\SubResourceProvider' - tags: - - name: 'api_platform.state_provider' - arguments: - $itemProvider: '@ApiPlatform\Doctrine\Orm\State\ItemProvider' diff --git a/tests/Hal/JsonSchema/SchemaFactoryTest.php b/tests/Hal/JsonSchema/SchemaFactoryTest.php index 582afef8d65..70cc17da0a9 100644 --- a/tests/Hal/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hal/JsonSchema/SchemaFactoryTest.php @@ -17,7 +17,6 @@ use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; -use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -39,7 +38,6 @@ class SchemaFactoryTest extends TestCase protected function setUp(): void { - $typeFactory = $this->prophesize(TypeFactoryInterface::class); $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataFactory->create(Dummy::class)->willReturn( new ResourceMetadataCollection(Dummy::class, [ @@ -52,7 +50,7 @@ protected function setUp(): void $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( - $typeFactory->reveal(), + null, $resourceMetadataFactory->reveal(), $propertyNameCollectionFactory->reveal(), $propertyMetadataFactory->reveal() diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index f33d75c3848..04001d90dbd 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -20,6 +20,11 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Library; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\TimestampableInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; @@ -169,6 +174,71 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } + public function testNormalizeWithUnionIntersectTypes(): void + { + $author = new Author(id: 2, name: 'Isaac Asimov'); + $library = new Library(id: 3, name: 'Le Bâteau Livre'); + $book = new Book(); + $book->author = $author; + $book->library = $library; + + $propertyNameCollection = new PropertyNameCollection(['author', 'library']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Book::class, [])->willReturn($propertyNameCollection); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Book::class, 'author', [])->willReturn( + (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_OBJECT, false, ActivableInterface::class), + new Type(Type::BUILTIN_TYPE_OBJECT, false, TimestampableInterface::class), + ])->withReadable(true) + ); + $propertyMetadataFactoryProphecy->create(Book::class, 'library', [])->willReturn( + (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_OBJECT, false, ActivableInterface::class), + new Type(Type::BUILTIN_TYPE_OBJECT, false, TimestampableInterface::class), + ])->withReadable(true) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($book, Argument::cetera())->willReturn('/books/1'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Book::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(ActivableInterface::class)->willReturn(false); + $resourceClassResolverProphecy->isResourceClass(TimestampableInterface::class)->willReturn(false); + $resourceClassResolverProphecy->getResourceClass($book, null)->willReturn(Book::class); + $resourceClassResolverProphecy->getResourceClass(null, Book::class)->willReturn(Book::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $nameConverter = $this->prophesize(NameConverterInterface::class); + $nameConverter->normalize('author', Argument::any(), Argument::any(), Argument::any())->willReturn('author'); + $nameConverter->normalize('library', Argument::any(), Argument::any(), Argument::any())->willReturn('library'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + $nameConverter->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '_links' => [ + 'self' => [ + 'href' => '/books/1', + ], + ], + 'author' => null, + 'library' => null, + ]; + $this->assertEquals($expected, $normalizer->normalize($book)); + } + public function testNormalizeWithoutCache(): void { $relatedDummy = new RelatedDummy(); diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index b400ffbfa56..28f609db394 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -17,7 +17,6 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; -use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -38,7 +37,6 @@ class SchemaFactoryTest extends TestCase protected function setUp(): void { - $typeFactory = $this->prophesize(TypeFactoryInterface::class); $resourceMetadataFactoryCollection = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataFactoryCollection->create(Dummy::class)->willReturn( new ResourceMetadataCollection(Dummy::class, [ @@ -53,7 +51,7 @@ protected function setUp(): void $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( - $typeFactory->reveal(), + null, $resourceMetadataFactoryCollection->reveal(), $propertyNameCollectionFactory->reveal(), $propertyMetadataFactory->reveal() diff --git a/tests/Hydra/Serializer/ErrorNormalizerTest.php b/tests/Hydra/Serializer/ErrorNormalizerTest.php deleted file mode 100644 index a94f29ac73f..00000000000 --- a/tests/Hydra/Serializer/ErrorNormalizerTest.php +++ /dev/null @@ -1,123 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Hydra\Serializer; - -use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Hydra\Serializer\ErrorNormalizer; -use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\ErrorHandler\Exception\FlattenException; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Serializer\Serializer; - -/** - * @author Kévin Dunglas - */ -class ErrorNormalizerTest extends TestCase -{ - use ProphecyTrait; - - /** - * @group legacy - */ - public function testSupportsNormalization(): void - { - $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); - - $normalizer = new ErrorNormalizer($urlGeneratorProphecy->reveal()); - - $this->assertTrue($normalizer->supportsNormalization(new \Exception(), ErrorNormalizer::FORMAT)); - $this->assertFalse($normalizer->supportsNormalization(new \Exception(), 'xml')); - $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); - - $this->assertTrue($normalizer->supportsNormalization(new FlattenException(), ErrorNormalizer::FORMAT)); - $this->assertFalse($normalizer->supportsNormalization(new FlattenException(), 'xml')); - $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); - $this->assertEmpty($normalizer->getSupportedTypes('json')); - $this->assertSame([ - \Exception::class => true, - FlattenException::class => true, - ], $normalizer->getSupportedTypes($normalizer::FORMAT)); - - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); - } - } - - /** - * @dataProvider providerStatusCode - * - * @param int $status http status code of the Exception - * @param string $originalMessage original message of the Exception - * @param bool $debug simulates kernel debug variable - */ - public function testErrorServerNormalize(int $status, string $originalMessage, bool $debug): void - { - $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); - $urlGeneratorProphecy->generate('api_jsonld_context', ['shortName' => 'Error'])->willReturn('/context/foo')->shouldBeCalled(); - - $normalizer = new ErrorNormalizer($urlGeneratorProphecy->reveal(), $debug); - $exception = FlattenException::create(new \Exception($originalMessage), $status); - - $expected = [ - '@context' => '/context/foo', - '@type' => 'hydra:Error', - 'hydra:title' => 'An error occurred', - 'hydra:description' => ($debug || $status < 500) ? $originalMessage : Response::$statusTexts[$status], - ]; - - if ($debug) { - $expected['trace'] = $exception->getTrace(); - } - - $this->assertSame($expected, $normalizer->normalize($exception, null, ['statusCode' => $status])); - } - - public static function providerStatusCode(): \Iterator - { - yield [Response::HTTP_INTERNAL_SERVER_ERROR, 'Sensitive SQL error displayed', false]; - yield [Response::HTTP_GATEWAY_TIMEOUT, 'Sensitive server error displayed', false]; - yield [Response::HTTP_BAD_REQUEST, 'Bad Request Message', false]; - yield [Response::HTTP_INTERNAL_SERVER_ERROR, 'Sensitive SQL error displayed', true]; - yield [Response::HTTP_GATEWAY_TIMEOUT, 'Sensitive server error displayed', true]; - yield [Response::HTTP_BAD_REQUEST, 'Bad Request Message', true]; - } - - public function testNormalize(): void - { - $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); - $urlGeneratorProphecy->generate('api_jsonld_context', ['shortName' => 'Error'])->willReturn('/context/foo')->shouldBeCalled(); - - $normalizer = new ErrorNormalizer($urlGeneratorProphecy->reveal()); - - $this->assertEquals( - [ - '@context' => '/context/foo', - '@type' => 'hydra:Error', - 'hydra:title' => 'An error occurred', - 'hydra:description' => 'Hello', - ], - $normalizer->normalize(new \Exception('Hello')) - ); - $this->assertEquals( - [ - '@context' => '/context/foo', - '@type' => 'hydra:Error', - 'hydra:title' => 'Hi', - 'hydra:description' => 'Hello', - ], - $normalizer->normalize(new \Exception('Hello'), null, ['title' => 'Hi']) - ); - } -} diff --git a/tests/JsonApi/Serializer/ErrorNormalizerTest.php b/tests/JsonApi/Serializer/ErrorNormalizerTest.php index 16debb60438..fe5e620e792 100644 --- a/tests/JsonApi/Serializer/ErrorNormalizerTest.php +++ b/tests/JsonApi/Serializer/ErrorNormalizerTest.php @@ -41,18 +41,20 @@ public function testSupportsNormalization(): void $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); $this->assertEmpty($normalizer->getSupportedTypes('json')); $this->assertSame([ - \Exception::class => true, - FlattenException::class => true, + \Exception::class => false, + FlattenException::class => false, ], $normalizer->getSupportedTypes($normalizer::FORMAT)); if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + $this->assertFalse($normalizer->hasCacheableSupportsMethod()); } } /** * @dataProvider errorProvider * + * @group legacy + * * @param int $status http status code of the Exception * @param string $originalMessage original message of the Exception * @param bool $debug simulates kernel debug variable @@ -74,6 +76,9 @@ public function testNormalize(int $status, string $originalMessage, bool $debug) $this->assertSame($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); } + /** + * @group legacy + */ public function testNormalizeAnExceptionWithCustomErrorCode(): void { $status = Response::HTTP_BAD_REQUEST; @@ -92,6 +97,9 @@ public function testNormalizeAnExceptionWithCustomErrorCode(): void $this->assertSame($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); } + /** + * @group legacy + */ public function testNormalizeAFlattenExceptionWithCustomErrorCode(): void { $status = Response::HTTP_BAD_REQUEST; diff --git a/tests/JsonLd/Action/ContextActionTest.php b/tests/JsonLd/Action/ContextActionTest.php index 45a17295b5d..69d5d486eca 100644 --- a/tests/JsonLd/Action/ContextActionTest.php +++ b/tests/JsonLd/Action/ContextActionTest.php @@ -55,7 +55,7 @@ public function testContextActionWithContexts(): void $contextBuilderProphecy->getBaseContext()->willReturn(['/contexts']); $contextAction = new ContextAction($contextBuilderProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal()); - $this->assertEquals(['@context' => ['/contexts']], $contextAction('Error')); + $this->assertEquals(['@context' => ['/contexts']], $contextAction('ConstraintViolationList')); } public function testContextActionWithResourceClass(): void diff --git a/tests/Problem/Serializer/ErrorNormalizerTest.php b/tests/Problem/Serializer/ErrorNormalizerTest.php index a3b3c665765..07c1e3ef8ac 100644 --- a/tests/Problem/Serializer/ErrorNormalizerTest.php +++ b/tests/Problem/Serializer/ErrorNormalizerTest.php @@ -40,15 +40,18 @@ public function testSupportNormalization(): void $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); $this->assertEmpty($normalizer->getSupportedTypes('json')); $this->assertSame([ - \Exception::class => true, - FlattenException::class => true, + \Exception::class => false, + FlattenException::class => false, ], $normalizer->getSupportedTypes($normalizer::FORMAT)); if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + $this->assertFalse($normalizer->hasCacheableSupportsMethod()); } } + /** + * @group legacy + */ public function testNormalize(): void { $normalizer = new ErrorNormalizer(); @@ -74,6 +77,8 @@ public function testNormalize(): void /** * @dataProvider providerStatusCode * + * @group legacy + * * @param int $status http status code of the Exception * @param string $originalMessage original message of the Exception * @param bool $debug simulates kernel debug variable @@ -96,6 +101,9 @@ public function testErrorServerNormalize(int $status, string $originalMessage, b $this->assertSame($expected, $normalizer->normalize($exception, null, ['statusCode' => $status])); } + /** + * @group legacy + */ public static function providerStatusCode(): \Iterator { yield [Response::HTTP_INTERNAL_SERVER_ERROR, 'Sensitive SQL error displayed', false]; @@ -107,6 +115,9 @@ public static function providerStatusCode(): \Iterator yield [509, Response::$statusTexts[Response::HTTP_INTERNAL_SERVER_ERROR], true]; } + /** + * @group legacy + */ public function testErrorServerNormalizeContextStatus(): void { $normalizer = new ErrorNormalizer(false); diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 2e7dc18405e..01a2341ff20 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -70,6 +70,7 @@ use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\RuntimeException; @@ -154,6 +155,7 @@ class ApiPlatformExtensionTest extends TestCase 'graphql' => [ 'graphql_playground' => ['enabled' => false], ], + 'keep_legacy_inflector' => false, ]]; private ContainerBuilder $container; @@ -1247,13 +1249,11 @@ public function testHttpCacheBanConfiguration(): void } /** - * @group legacy - * * @doesNotPerformAssertions */ public function testLegacyOpenApiApiKeysConfiguration(): void { - $this->expectDeprecation('Since api-platform/core 3.1: The swagger api_keys key "Some Authorization Name" is not valid with OpenAPI 3.1 it should match "^[a-zA-Z0-9._-]+$"'); + $this->expectException(InvalidConfigurationException::class); $config = self::DEFAULT_CONFIG; $config['api_platform']['swagger']['api_keys']['Some Authorization Name'] = ['name' => 'a', 'type' => 'header']; diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 5e45de75024..fdddafac6d4 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -121,6 +121,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'graphiql' => [ 'enabled' => true, ], + 'introspection' => [ + 'enabled' => true, + ], 'nesting_separator' => '_', 'collection' => [ 'pagination' => [ @@ -217,6 +220,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'maker' => [ 'enabled' => true, ], + 'keep_legacy_inflector' => true, ], $config); } @@ -276,6 +280,26 @@ public function testExceptionToStatusConfigWithInvalidHttpStatusCodeValue($inval ]); } + /** + * Test config for api keys. + */ + public function testInvalidApiKeysConfig(): void + { + $this->expectExceptionMessage('The api keys "key" is not valid according to the pattern enforced by OpenAPI 3.1 ^[a-zA-Z0-9._-]+$.'); + $exampleConfig = [ + 'name' => 'Authorization', + 'type' => 'query', + ]; + + $config = $this->processor->processConfiguration($this->configuration, [ + 'api_platform' => [ + 'swagger' => [ + 'api_keys' => ['Some Authorization name, like JWT' => $exampleConfig, 'Another-Auth' => $exampleConfig], + ], + ], + ]); + } + /** * Test config for api keys. */ @@ -289,13 +313,13 @@ public function testApiKeysConfig(): void $config = $this->processor->processConfiguration($this->configuration, [ 'api_platform' => [ 'swagger' => [ - 'api_keys' => ['Some Authorization name, like JWT' => $exampleConfig], + 'api_keys' => ['authorization_name_like_JWT' => $exampleConfig], ], ], ]); $this->assertArrayHasKey('api_keys', $config['swagger']); - $this->assertSame($exampleConfig, $config['swagger']['api_keys']['Some Authorization name, like JWT']); + $this->assertSame($exampleConfig, $config['swagger']['api_keys']['authorization_name_like_JWT']); } /** diff --git a/tests/HttpCache/EventListener/AddHeadersListenerTest.php b/tests/Symfony/EventListener/AddHeadersListenerTest.php similarity index 99% rename from tests/HttpCache/EventListener/AddHeadersListenerTest.php rename to tests/Symfony/EventListener/AddHeadersListenerTest.php index 035c8405670..3ded8bff669 100644 --- a/tests/HttpCache/EventListener/AddHeadersListenerTest.php +++ b/tests/Symfony/EventListener/AddHeadersListenerTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\HttpCache\EventListener; +namespace ApiPlatform\Tests\Symfony\EventListener; -use ApiPlatform\HttpCache\EventListener\AddHeadersListener; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Symfony\EventListener\AddHeadersListener; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/tests/HttpCache/EventListener/AddTagsListenerTest.php b/tests/Symfony/EventListener/AddTagsListenerTest.php similarity index 98% rename from tests/HttpCache/EventListener/AddTagsListenerTest.php rename to tests/Symfony/EventListener/AddTagsListenerTest.php index 532d7f9406d..75ce8e515e5 100644 --- a/tests/HttpCache/EventListener/AddTagsListenerTest.php +++ b/tests/Symfony/EventListener/AddTagsListenerTest.php @@ -11,17 +11,17 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\HttpCache\EventListener; +namespace ApiPlatform\Tests\Symfony\EventListener; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\HttpCache\EventListener\AddTagsListener; use ApiPlatform\HttpCache\PurgerInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Symfony\EventListener\AddTagsListener; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Prophecy\Argument; diff --git a/tests/Symfony/EventListener/ErrorListenerTest.php b/tests/Symfony/EventListener/ErrorListenerTest.php new file mode 100644 index 00000000000..30b2d03a331 --- /dev/null +++ b/tests/Symfony/EventListener/ErrorListenerTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\EventListener; + +use ApiPlatform\Api\IdentifiersExtractorInterface; +use ApiPlatform\ApiResource\Error; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Symfony\EventListener\ErrorListener; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelInterface; + +final class ErrorListenerTest extends TestCase +{ + use ProphecyTrait; + + public function testDuplicateException(): void + { + $exception = new \Exception(); + $operation = new Get(name: '_api_errors_problem', priority: 0, status: 400); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Error::class) + ->willReturn(new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])])); + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass($exception::class)->willReturn(false); + $kernel = $this->prophesize(KernelInterface::class); + $kernel->handle(Argument::that(function ($request) use ($operation) { + $this->assertTrue($request->attributes->has('_api_exception_swagger_data')); + $this->assertEquals('_api_errors_problem', $request->attributes->get('_api_operation_name')); + $this->assertTrue($request->attributes->get('_api_error')); + $this->assertEquals($request->attributes->get('_api_operation'), $operation); + $this->assertEquals($request->attributes->get('_api_resource_class'), Error::class); + $this->assertInstanceOf(Error::class, $request->attributes->get('data')); + + return true; + }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); + $exceptionEvent = new ExceptionEvent($kernel->reveal(), Request::create('/'), HttpKernelInterface::SUB_REQUEST, $exception); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonproblem' => ['application/problem+json']], [], null, $resourceClassResolver->reveal()); + $errorListener->onKernelException($exceptionEvent); + } + + public function testDuplicateExceptionWithHydra(): void + { + $exception = new \Exception(); + $operation = new Get(name: '_api_errors_hydra', priority: 0, status: 400); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Error::class) + ->willReturn(new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])])); + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass($exception::class)->willReturn(false); + $kernel = $this->prophesize(KernelInterface::class); + $kernel->handle(Argument::that(function ($request) use ($operation) { + $this->assertTrue($request->attributes->has('_api_exception_swagger_data')); + $this->assertEquals('_api_errors_hydra', $request->attributes->get('_api_operation_name')); + $this->assertTrue($request->attributes->get('_api_error')); + $this->assertEquals($request->attributes->get('_api_operation'), $operation); + $this->assertEquals($request->attributes->get('_api_resource_class'), Error::class); + $this->assertInstanceOf(Error::class, $request->attributes->get('data')); + + return true; + }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); + $exceptionEvent = new ExceptionEvent($kernel->reveal(), Request::create('/'), HttpKernelInterface::SUB_REQUEST, $exception); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonld' => ['application/ld+json']], [], null, $resourceClassResolver->reveal()); + $errorListener->onKernelException($exceptionEvent); + } + + public function testDuplicateExceptionWithErrorResource(): void + { + $exception = Error::createFromException(new \Exception(), 400); + $operation = new Get(name: '_api_errors_hydra', priority: 0, status: 400, outputFormats: ['jsonld' => ['application/ld+json']]); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Error::class) + ->willReturn(new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])])); + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Error::class)->willReturn(true); + $kernel = $this->prophesize(KernelInterface::class); + $kernel->handle(Argument::that(function ($request) use ($operation) { + $this->assertTrue($request->attributes->has('_api_exception_swagger_data')); + $this->assertEquals('_api_errors_hydra', $request->attributes->get('_api_operation_name')); + $this->assertTrue($request->attributes->get('_api_error')); + $this->assertEquals($request->attributes->get('_api_operation'), $operation); + $this->assertEquals($request->attributes->get('_api_resource_class'), Error::class); + $this->assertEquals($request->attributes->get('id'), 1); + $this->assertInstanceOf(Error::class, $request->attributes->get('data')); + + return true; + }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); + $exceptionEvent = new ExceptionEvent($kernel->reveal(), Request::create('/'), HttpKernelInterface::SUB_REQUEST, $exception); + $identifiersExtractor = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractor->getIdentifiersFromItem($exception, $operation)->willReturn(['id' => 1]); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonld' => ['application/ld+json']], [], $identifiersExtractor->reveal(), $resourceClassResolver->reveal()); + $errorListener->onKernelException($exceptionEvent); + } +} diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php index 955cee82445..f1506c5d37c 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php @@ -46,7 +46,14 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public static function supportsProvider(): \Generator { - yield 'supported' => [new Choice(['choices' => ['a', 'b']]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), true]; + yield 'supported string' => [new Choice(['choices' => ['a', 'b']]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), true]; + yield 'supported int' => [new Choice(['choices' => [1, 2]]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; + yield 'supported float' => [new Choice(['choices' => [1.1, 2.2]]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; + yield 'supported string/int/float with union types' => [new Choice(['choices' => [1, 2, 1.1, 2.2, 'a', 'b']]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_FLOAT), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_STRING), + ]), true]; yield 'not supported constraint' => [new Positive(), new ApiProperty(), false]; yield 'not supported type' => [new Choice(['choices' => [new \stdClass(), new \stdClass()]]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)]), false]; @@ -80,6 +87,32 @@ public static function createProvider(): \Generator yield 'multi float choice max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]]; yield 'multi float choice min/max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'min' => 2, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]]; + yield 'single string/int/float choice with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2]]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['enum' => [1, 2, 'a', 'b', 1.1, 2.2]]]; + yield 'multi string/int/float choice with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2], 'multiple' => true]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]]]]; + yield 'multi string/int/float choice min with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2], 'multiple' => true, 'min' => 2]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]], 'minItems' => 2]]; + yield 'multi string/int/float choice max with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]]; + yield 'multi string/int/float choice min/max with union types' => [new Choice(['choices' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'min' => 2, 'max' => 4]), (new ApiProperty())->withBuiltinTypes([ + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_FLOAT), + ]), ['type' => 'array', 'items' => ['type' => ['string', 'number'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]]; + yield 'single choice callback' => [new Choice(['callback' => ChoiceCallback::getChoices(...)]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), ['enum' => ['a', 'b', 'c', 'd']]]; yield 'multi choice callback' => [new Choice(['callback' => ChoiceCallback::getChoices(...), 'multiple' => true]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]]; } diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php index 6280bd3f0e4..3411366660d 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php @@ -47,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public static function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new GreaterThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new GreaterThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new GreaterThanOrEqual(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported positive or zero' => [new PositiveOrZero(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index 486a47f0304..bbf45247146 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -20,7 +20,6 @@ use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GreaterThan; -use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; use Symfony\Component\Validator\Constraints\Positive; use Symfony\Component\Validator\Constraints\PositiveOrZero; @@ -48,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public static function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new GreaterThan(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported positive' => [new Positive(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; @@ -60,6 +60,6 @@ public function testCreate(): void self::assertEquals([ 'minimum' => 10, 'exclusiveMinimum' => true, - ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]))); + ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]))); } } diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php index 97eb1a64c80..c11f7289b0e 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php @@ -47,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public static function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new LessThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new LessThanOrEqual(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new LessThanOrEqual(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported negative or zero' => [new NegativeOrZero(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index 866c37b5181..84e2c1daa64 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -47,6 +47,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public static function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new LessThan(['value' => 10.99]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported negative' => [new Negative(), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php index 2ec2b82c965..19ec7b816e6 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php @@ -46,6 +46,7 @@ public function testSupports(Constraint $constraint, ApiProperty $propertyMetada public static function supportsProvider(): \Generator { + yield 'supported int/float with union types' => [new Range(['min' => 1, 'max' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; yield 'supported int' => [new Range(['min' => 1, 'max' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), true]; yield 'supported float' => [new Range(['min' => 1, 'max' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_FLOAT)]), true]; diff --git a/tests/Symfony/Validator/Metadata/ValidationExceptionListenerTest.php b/tests/Symfony/Validator/Metadata/ValidationExceptionListenerTest.php index ffba24aac7b..d39137cddd2 100644 --- a/tests/Symfony/Validator/Metadata/ValidationExceptionListenerTest.php +++ b/tests/Symfony/Validator/Metadata/ValidationExceptionListenerTest.php @@ -36,6 +36,9 @@ class ValidationExceptionListenerTest extends TestCase { use ProphecyTrait; + /** + * @group legacy + */ public function testNotValidationException(): void { if (!class_exists(Countries::class)) { @@ -51,6 +54,9 @@ public function testNotValidationException(): void $this->assertNull($event->getResponse()); } + /** + * @group legacy + */ public function testValidationException(): void { $exceptionJson = '{"foo": "bar"}'; @@ -72,6 +78,9 @@ public function testValidationException(): void $this->assertSame('deny', $response->headers->get('X-Frame-Options')); } + /** + * @group legacy + */ public function testOnKernelValidationExceptionWithCustomStatus(): void { $serializedConstraintViolationList = '{"foo": "bar"}'; @@ -114,6 +123,9 @@ public function getConstraintViolationList(): ConstraintViolationListInterface self::assertSame('deny', $response->headers->get('X-Frame-Options')); } + /** + * @group legacy + */ public function testValidationFilterException(): void { $exceptionJson = '{"message": "my message"}'; @@ -135,6 +147,9 @@ public function testValidationFilterException(): void $this->assertSame('deny', $response->headers->get('X-Frame-Options')); } + /** + * @group legacy + */ public function testValidationExceptionWithHydraTitle(): void { $exceptionJson = '{"foo": "bar"}';