From 09a2db8acb5c4d4778393a9196f8230d2fcc1822 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Fri, 5 Aug 2022 15:41:09 +0200 Subject: [PATCH 01/12] feat: add NotExposed operation --- features/main/not_exposed.feature | 193 ++++++++++++++++++ src/Action/NotExposedAction.php | 33 +++ .../Serializer/DocumentationNormalizer.php | 5 + src/Exception/NotExposedHttpException.php | 23 +++ src/JsonApi/Serializer/ObjectNormalizer.php | 4 +- src/JsonLd/ContextBuilder.php | 4 +- .../Extractor/YamlResourceExtractor.php | 8 +- src/Metadata/NotExposed.php | 110 ++++++++++ ...ationResourceMetadataCollectionFactory.php | 81 ++++++++ src/OpenApi/Factory/OpenApiFactory.php | 10 + src/Symfony/Bundle/Resources/config/api.xml | 2 + .../Resources/config/metadata/resource.xml | 5 + .../Bundle/Resources/config/routing/genid.xml | 13 ++ src/Symfony/Routing/ApiLoader.php | 2 + src/Symfony/Routing/IriConverter.php | 9 +- src/Util/SkolemTrait.php | 32 +++ tests/Fixtures/TestBundle/Model/Chair.php | 36 ++++ tests/Fixtures/TestBundle/Model/Fork.php | 37 ++++ tests/Fixtures/TestBundle/Model/Spoon.php | 36 ++++ tests/Fixtures/TestBundle/Model/Table.php | 36 ++++ .../config/api_resources_v3/resources.yaml | 41 ++++ .../TestBundle/State/FakeProvider.php | 40 ++++ tests/Fixtures/app/config/config_common.yml | 4 + ...nResourceMetadataCollectionFactoryTest.php | 188 +++++++++++++++++ tests/OpenApi/Factory/OpenApiFactoryTest.php | 3 + tests/Symfony/Routing/IriConverterTest.php | 16 ++ 26 files changed, 965 insertions(+), 6 deletions(-) create mode 100644 features/main/not_exposed.feature create mode 100644 src/Action/NotExposedAction.php create mode 100644 src/Exception/NotExposedHttpException.php create mode 100644 src/Metadata/NotExposed.php create mode 100644 src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php create mode 100644 src/Symfony/Bundle/Resources/config/routing/genid.xml create mode 100644 src/Util/SkolemTrait.php create mode 100644 tests/Fixtures/TestBundle/Model/Chair.php create mode 100644 tests/Fixtures/TestBundle/Model/Fork.php create mode 100644 tests/Fixtures/TestBundle/Model/Spoon.php create mode 100644 tests/Fixtures/TestBundle/Model/Table.php create mode 100644 tests/Fixtures/TestBundle/State/FakeProvider.php create mode 100644 tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php diff --git a/features/main/not_exposed.feature b/features/main/not_exposed.feature new file mode 100644 index 00000000000..964e4fa45df --- /dev/null +++ b/features/main/not_exposed.feature @@ -0,0 +1,193 @@ +@php8 +@v3 +Feature: Expose only a collection of objects + + # A NotExposed operation with "routeName: api_genid" is automatically added to this resource. + Scenario: Get a collection of objects without identifiers from a single resource with a single collection + When I send a "GET" request to "/chairs" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Chair$"}, + "@id": {"pattern": "^/chairs$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^Chair$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + # A NotExposed operation with a valid path (e.g.: "/tables/{id}") is automatically added to this resource. + Scenario: Get a collection of objects with identifiers from a single resource with a single collection + When I send a "GET" request to "/tables" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Table$"}, + "@id": {"pattern": "^/tables$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/tables/.+$"}, + "@type": {"pattern": "^Table$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + # A NotExposed operation with a valid path (e.g.: "/forks/{id}") is automatically added to the last resource. + # This operation does not inherit from the resource uriTemplate as it's not intended to. + Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections + When I send a "GET" request to "" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Fork$"}, + "@id": {"pattern": "^"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/forks/.+$"}, + "@type": {"pattern": "^Fork$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + Examples: + | uri | + | /forks | + | /fourchettes | + + + # A NotExposed operation is not automatically added. + Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections and an item operation + When I send a "GET" request to "" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Spoon$"}, + "@id": {"pattern": "^"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/cuillers/.+$"}, + "@type": {"pattern": "^Spoon$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + }, + "minItems": 2, + "maxItems": 2, + "uniqueItems": true + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + Examples: + | uri | + | /spoons | + | /cuillers | + + Scenario Outline: Get a not exposed route returns a 404 with an explanation + When I send a "GET" request to "" + Then the response status code should be 404 + 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 "" + Examples: + | uri | hydra:description | + | /.well-known/genid/12345 | This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation. | + | /tables/12345 | This route does not aim to be called. | + | /forks/12345 | This route does not aim to be called. | + + Scenario: Get a single item still works + When I send a "GET" request to "/cuillers/12345" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Spoon", + "@id": "/cuillers/12345", + "@type": "Spoon", + "id": "12345", + "owner": "Vincent" + } + """ diff --git a/src/Action/NotExposedAction.php b/src/Action/NotExposedAction.php new file mode 100644 index 00000000000..cfee48b3ffe --- /dev/null +++ b/src/Action/NotExposedAction.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\Action; + +use ApiPlatform\Exception\NotExposedHttpException; +use Symfony\Component\HttpFoundation\Request; + +/** + * An action which always returns HTTP 404 Not Found with an explanation for why the operation is not exposed. + */ +final class NotExposedAction +{ + public function __invoke(Request $request): never + { + $message = 'This route does not aim to be called.'; + if ('api_genid' === $request->attributes->get('_route')) { + $message = 'This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.'; + } + + throw new NotExposedHttpException($message); + } +} diff --git a/src/Core/Swagger/Serializer/DocumentationNormalizer.php b/src/Core/Swagger/Serializer/DocumentationNormalizer.php index 7ab1e3d3781..ca74108fbc2 100644 --- a/src/Core/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Core/Swagger/Serializer/DocumentationNormalizer.php @@ -274,6 +274,11 @@ private function addPaths(bool $v3, \ArrayObject $paths, \ArrayObject $definitio } foreach ($operations as $operationName => $operation) { + // Skolem IRI + if ('api_genid' === ($operation['route_name'] ?? null)) { + continue; + } + if (isset($operation['uri_template'])) { $path = str_replace('.{_format}', '', $operation['uri_template']); if (0 !== strpos($path, '/')) { diff --git a/src/Exception/NotExposedHttpException.php b/src/Exception/NotExposedHttpException.php new file mode 100644 index 00000000000..5092738d046 --- /dev/null +++ b/src/Exception/NotExposedHttpException.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\Exception; + +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * @author Vincent Chalamon + */ +class NotExposedHttpException extends NotFoundHttpException +{ +} diff --git a/src/JsonApi/Serializer/ObjectNormalizer.php b/src/JsonApi/Serializer/ObjectNormalizer.php index dd69748218f..eff6f44a038 100644 --- a/src/JsonApi/Serializer/ObjectNormalizer.php +++ b/src/JsonApi/Serializer/ObjectNormalizer.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Util\ClassInfoTrait; +use ApiPlatform\Util\SkolemTrait; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -31,6 +32,7 @@ final class ObjectNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface { use ClassInfoTrait; + use SkolemTrait; public const FORMAT = 'jsonapi'; @@ -102,7 +104,7 @@ public function normalize($object, $format = null, array $context = []) ]; } else { $resourceData = [ - 'id' => '/.well-known/genid/'.bin2hex(random_bytes(10)), + 'id' => $this->generateSkolemIri($object), 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), ]; } diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 533105afe45..7ebd03523d3 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Util\ClassInfoTrait; +use ApiPlatform\Util\SkolemTrait; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -36,6 +37,7 @@ final class ContextBuilder implements AnonymousContextBuilderInterface { use ClassInfoTrait; + use SkolemTrait; public const FORMAT = 'jsonld'; @@ -188,7 +190,7 @@ public function getAnonymousResourceContext($object, array $context = [], int $r ]; if (!isset($context['iri']) || false !== $context['iri']) { - $jsonLdContext['@id'] = $context['iri'] ?? '/.well-known/genid/'.bin2hex(random_bytes(10)); + $jsonLdContext['@id'] = $context['iri'] ?? $this->generateSkolemIri($object); } if ($context['has_context'] ?? false) { diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index c2473369807..efbda98dfe2 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -283,12 +283,16 @@ private function buildOperations(array $resource, array $root): ?array private function buildGraphQlOperations(array $resource, array $root): ?array { - if (!\array_key_exists('graphQlOperations', $resource)) { + if (!\array_key_exists('graphQlOperations', $resource) || !\is_array($resource['graphQlOperations'])) { return null; } $data = []; foreach (['mutations' => Mutation::class, 'queries' => Query::class, 'subscriptions' => Subscription::class] as $type => $class) { + if (!\array_key_exists($type, $resource['graphQlOperations'])) { + continue; + } + foreach ($resource['graphQlOperations'][$type] as $operation) { $datum = $this->buildBase($operation); foreach ($datum as $key => $value) { @@ -322,6 +326,6 @@ private function buildGraphQlOperations(array $resource, array $root): ?array } } - return $data; + return $data ?: null; } } diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php new file mode 100644 index 00000000000..59c05731a9d --- /dev/null +++ b/src/Metadata/NotExposed.php @@ -0,0 +1,110 @@ + + * + * For 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; + +/** + * A NotExposed operation is an operation declared for internal usage, + * for example to generate an IRI on a resource without item operations. + * It is ignored from OpenApi documentation and must return a HTTP 404. + * + * @internal + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class NotExposed extends HttpOperation +{ + /** + * {@inheritdoc} + */ + public function __construct( + string $method = self::METHOD_GET, + ?string $uriTemplate = null, + ?array $types = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + ?string $routePrefix = null, + ?string $routeName = null, + ?array $defaults = null, + ?array $requirements = null, + ?array $options = null, + ?bool $stateless = null, + ?string $sunset = null, + ?string $acceptPatch = null, + $status = null, + ?string $host = null, + ?array $schemes = null, + ?string $condition = null, + ?string $controller = 'api_platform.action.not_exposed', + ?array $cacheHeaders = null, + + ?array $hydraContext = null, + ?array $openapiContext = null, + ?array $exceptionToStatus = null, + + ?bool $queryParameterValidationEnabled = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $paginationViaCursor = null, + ?array $order = null, + ?string $description = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?string $security = null, + ?string $securityMessage = null, + ?string $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + ?string $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = false, + $mercure = null, + $messenger = null, + ?bool $elasticsearch = null, + ?int $urlGenerationStrategy = null, + ?bool $read = false, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + ?string $name = null, + $provider = null, + $processor = null, + array $extraProperties = [] + ) { + parent::__construct(...\func_get_args()); + + // Declare overridden parameters because "func_get_args" does not handle default values + $this->controller = $controller; + $this->output = $output; + $this->read = $read; + } +} diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..21b1bf69100 --- /dev/null +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -0,0 +1,81 @@ + + * + * For 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\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +/** + * Adds a {@see NotExposed} operation with {@see NotFoundAction} on a resource which only has a GetCollection. + * This operation helps to generate resource IRI for items. + * + * @author Vincent Chalamon + * @experimental + */ +final class NotExposedOperationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + private $linkFactory; + private $decorated; + + public function __construct(LinkFactoryInterface $linkFactory, ?ResourceMetadataCollectionFactoryInterface $decorated = null) + { + $this->linkFactory = $linkFactory; + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass); + if ($this->decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + // Do not add a NotExposed operation on a resourceClass with no resource + if (!$resourceMetadataCollection->count()) { + return $resourceMetadataCollection; + } + + foreach ($resourceMetadataCollection as $resource) { + /** @var ApiResource $resource */ + $operations = $resource->getOperations(); + + foreach ($operations as $operation) { + // Ignore collection and GraphQL operations + if ($operation instanceof CollectionOperationInterface || $operation instanceof GraphQlOperation) { + continue; + } + + // An item operation has been found, nothing to do anymore in this factory + return $resourceMetadataCollection; + } + } + + // No item operation has been found on all resources for resource class: generate one on the last resource + // Helpful to generate an IRI for a resource without declaring the Get operation + // @phpstan-ignore-next-line + $operation = (new NotExposed())->withResource($resource)->withUriTemplate(null); // force uriTemplate to null to don't inherit it from resource + if (!$this->linkFactory->createLinksFromIdentifiers($resource)) { // @phpstan-ignore-line + $operation = $operation->withRouteName('api_genid'); + } + $operations->add(sprintf('_api_%s_get', $resource->getShortName()), $operation)->sort(); // @phpstan-ignore-line + + return $resourceMetadataCollection; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 79d9e16e1db..dfe9ebdeed5 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -141,6 +141,11 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + // Skolem IRI + if ('api_genid' === $operation->getRouteName()) { + continue; + } + $uriVariables = $operation->getUriVariables(); $resourceClass = $operation->getClass() ?? $resource->getClass(); $routeName = $operation->getRouteName() ?? $operation->getName(); @@ -392,6 +397,11 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection continue; } + // Skolem IRI + if ('api_genid' === $operation->getRouteName()) { + continue; + } + $operationUriVariables = $operation->getUriVariables(); foreach ($currentOperation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { if (!isset($operationUriVariables[$parameterName])) { diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 30b0043ce66..c5adc580b90 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -104,7 +104,9 @@ + + diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index dcefe35db1d..3e011de0d62 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -23,6 +23,11 @@ %api_platform.defaults% + + + + + diff --git a/src/Symfony/Bundle/Resources/config/routing/genid.xml b/src/Symfony/Bundle/Resources/config/routing/genid.xml new file mode 100644 index 00000000000..5f1a120f94a --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/routing/genid.xml @@ -0,0 +1,13 @@ + + + + + + api_platform.action.not_exposed + true + + + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index 5a7459efdd6..07eba2e7151 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -183,6 +183,8 @@ public function supports($resource, $type = null): bool */ private function loadExternalFiles(RouteCollection $routeCollection): void { + $routeCollection->addCollection($this->fileLoader->load('genid.xml')); + if ($this->entrypointEnabled) { $routeCollection->addCollection($this->fileLoader->load('api.xml')); } diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 848943f3c31..73dfae69151 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -165,9 +165,14 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter } } - // TODO: call the Skolem IRI generator if (!$operation->getName()) { - return null; + // Generate Skolem uri for unnamed operation + $operation = $operation->withName('api_genid'); + } + + if ('api_genid' === $operation->getName()) { + // If $item is not an object (can be a class name), generate a random id + $identifiers = ['id' => \is_object($item) ? spl_object_hash($item) : bin2hex(random_bytes(10))]; } try { diff --git a/src/Util/SkolemTrait.php b/src/Util/SkolemTrait.php new file mode 100644 index 00000000000..add0aebb588 --- /dev/null +++ b/src/Util/SkolemTrait.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\Util; + +/** + * Generates a Skolem IRI. + * + * @internal + * + * @author Vincent Chalamon + */ +trait SkolemTrait +{ + /** + * @param object $object + */ + private function generateSkolemIri($object): string + { + return '/.well-known/genid/'.spl_object_hash($object); + } +} diff --git a/tests/Fixtures/TestBundle/Model/Chair.php b/tests/Fixtures/TestBundle/Model/Chair.php new file mode 100644 index 00000000000..15bdd74b799 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Chair.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\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Single resource without identifiers and with a single collection. + * A NotExposed operation with "routeName: api_genid" is automatically added to this resource. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Chair +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Fork.php b/tests/Fixtures/TestBundle/Model/Fork.php new file mode 100644 index 00000000000..c8ffc30134a --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Fork.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\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Multiple resources with an identifier and multiple collections. + * A NotExposed operation with a valid path (e.g.: "/forks/{id}") is automatically added to the last resource. + * This operation does not inherit from the resource uriTemplate as it's not intended to. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Fork +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Spoon.php b/tests/Fixtures/TestBundle/Model/Spoon.php new file mode 100644 index 00000000000..6bad032f025 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Spoon.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\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Multiple resources with an identifier and multiple collections and an item operation. + * A NotExposed operation is not automatically added. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Spoon +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Model/Table.php b/tests/Fixtures/TestBundle/Model/Table.php new file mode 100644 index 00000000000..bf21dfb405b --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Table.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\Tests\Fixtures\TestBundle\Model; + +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; + +/** + * Single resource with an identifier and a single collection. + * A NotExposed operation with a valid path (e.g.: "/tables/{id}") is automatically added to this resource. + * + * @see NotExposedOperationResourceMetadataCollectionFactory::create() + * + * @author Vincent Chalamon + */ +class Table +{ + public $id; + public $owner; + + public function __construct($id, $owner) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml index 9346211022b..88ffb054bd3 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml @@ -3,6 +3,10 @@ properties: foo: identifier: true + ApiPlatform\Tests\Fixtures\TestBundle\Model\Chair: + id: + identifier: false + resources: ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyAddress: operations: @@ -20,4 +24,41 @@ resources: ApiPlatform\Metadata\GetCollection: ~ ApiPlatform\Metadata\Get: ~ + ApiPlatform\Tests\Fixtures\TestBundle\Model\Chair: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Fork: + - graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + - uriTemplate: /fourchettes + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Spoon: + - graphQlOperations: null + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + - graphQlOperations: ~ + operations: + ApiPlatform\Metadata\GetCollection: + uriTemplate: /cuillers + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + ApiPlatform\Metadata\Get: + uriTemplate: /cuillers/{id} + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Table: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + ApiPlatform\Tests\Fixtures\TestBundle\Entity\FlexConfig: ~ diff --git a/tests/Fixtures/TestBundle/State/FakeProvider.php b/tests/Fixtures/TestBundle/State/FakeProvider.php new file mode 100644 index 00000000000..158e67afbbf --- /dev/null +++ b/tests/Fixtures/TestBundle/State/FakeProvider.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\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +final class FakeProvider implements ProviderInterface +{ + /** + * {@inheritDoc} + * + * @return array|object|null + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []) + { + $className = $operation->getClass(); + $data = [ + '12345' => new $className('12345', 'Vincent'), + '67890' => new $className('67890', 'Grégoire'), + ]; + + if ($uriVariables) { + return $data[$uriVariables['id']] ?? null; + } + + return array_values($data); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index b8b19af66dd..c1410666638 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -134,6 +134,10 @@ services: tags: - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider' + tags: + - { name: 'api_platform.state_provider' } ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider' diff --git a/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..149f9e500f2 --- /dev/null +++ b/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,188 @@ + + * + * For 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\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\NotExposedOperationResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResource; +use ApiPlatform\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; + +/** + * @author Vincent Chalamon + */ +class NotExposedOperationResourceMetadataCollectionFactoryTest extends TestCase +{ + use ProphecyTrait; + + public function testItIgnoresClassesWithoutResources() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->shouldNotBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, []), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, []), + $factory->create(AttributeResource::class) + ); + } + + public function testItIgnoresResourcesWithAnItemOperation() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->shouldNotBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get' => new Get(uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], + class: AttributeResource::class + ), + ]), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get' => new Get(uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'])], + class: AttributeResource::class + ), + ]), + $factory->create(AttributeResource::class) + ); + } + + public function testItAddsANotExposedOperationWithoutRouteNameOnTheLastResource() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->willReturn([new Link()])->shouldBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + class: AttributeResource::class + ), + ]), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get' => new NotExposed(routeName: null, controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), + ], + class: AttributeResource::class + ), + ]), + $factory->create(AttributeResource::class) + ); + } + + public function testItAddsANotExposedOperationWithRouteNameOnTheLastResource() + { + $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->willReturn([])->shouldBeCalled(); + + $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + ], + class: AttributeResource::class + ), + ]), + ); + + $factory = new NotExposedOperationResourceMetadataCollectionFactory($linkFactoryProphecy->reveal(), $resourceCollectionMetadataFactoryProphecy->reveal()); + $this->assertEquals( + new ResourceMetadataCollection(AttributeResource::class, [ + new ApiResource( + shortName: 'AttributeResource', + operations: [], + class: AttributeResource::class + ), + new ApiResource( + shortName: 'AttributeResource', + operations: [ + '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), + '_api_AttributeResource_get' => new NotExposed(routeName: 'api_genid', controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), + ], + class: AttributeResource::class + ), + ]), + $factory->create(AttributeResource::class) + ); + } +} diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 170e031134e..74c8d25f0c0 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -69,6 +70,8 @@ public function testInvoke(): void 'class' => OutputDto::class, ])->withPaginationClientItemsPerPage(true)->withShortName('Dummy')->withDescription('This is a dummy'); $dummyResource = (new ApiResource())->withOperations(new Operations([ + 'ignored' => new NotExposed(), + 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), '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'])]), diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index 6befb079397..1adaf61ff25 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -157,6 +158,21 @@ public function testGetCollectionIri() $this->assertEquals('/dummies', $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation)); } + public function testGetGenidIriFromUnnamedOperation() + { + $operation = new NotExposed(); + $route = '/.well-known/genid/42'; + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('api_genid', Argument::type('array'), UrlGeneratorInterface::ABS_PATH)->shouldBeCalled()->willReturn($route); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [])); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy); + $this->assertEquals($route, $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation)); + } + public function testGetIriFromResourceClassWithIdentifiers() { $operationName = 'operation_name'; From 10aba6f2f9ced20dcf07b5f13dfbc116d09f23c2 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Tue, 9 Aug 2022 12:06:41 +0200 Subject: [PATCH 02/12] feat: add openapi operation option --- src/Metadata/Delete.php | 1 + src/Metadata/Extractor/XmlResourceExtractor.php | 1 + src/Metadata/Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/Get.php | 1 + src/Metadata/GetCollection.php | 1 + src/Metadata/HttpOperation.php | 16 ++++++++++++++++ src/Metadata/NotExposed.php | 2 ++ src/Metadata/Patch.php | 1 + src/Metadata/Post.php | 1 + src/Metadata/Put.php | 1 + src/OpenApi/Factory/OpenApiFactory.php | 10 ++++++++++ tests/Metadata/Extractor/XmlExtractorTest.php | 2 ++ tests/Metadata/Extractor/YamlExtractorTest.php | 2 ++ 14 files changed, 41 insertions(+) diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 1fc82acc3d1..ec5a91f48b4 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 35b83cd9aea..5c52cdeeb1b 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -295,6 +295,7 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr } $data[] = array_merge($datum, [ + 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'collection' => $this->phpize($operation, 'collection', 'bool'), 'class' => (string) $operation['class'], 'method' => $this->phpize($operation, 'method', 'string'), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index efbda98dfe2..dabcc4eaf92 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -268,6 +268,7 @@ private function buildOperations(array $resource, array $root): ?array $data[] = array_merge($datum, [ 'read' => $this->phpize($operation, 'read', 'bool'), 'deserialize' => $this->phpize($operation, 'deserialize', 'bool'), + 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'validate' => $this->phpize($operation, 'validate', 'bool'), 'write' => $this->phpize($operation, 'write', 'bool'), 'serialize' => $this->phpize($operation, 'serialize', 'bool'), diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 7202feb23c7..02cf1182ed7 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -44,6 +44,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index b599284199b..d515e8e57b3 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 5723b671ba3..dacd5745084 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 78074df33cc..5adea99e60e 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -69,6 +69,7 @@ class HttpOperation extends Operation */ protected $hydraContext; protected $openapiContext; + protected $openapi; protected $exceptionToStatus; @@ -145,6 +146,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -225,6 +227,7 @@ public function __construct( $this->denormalizationContext = $denormalizationContext; $this->hydraContext = $hydraContext; $this->openapiContext = $openapiContext; + $this->openapi = $openapi; $this->validationContext = $validationContext; $this->filters = $filters; $this->elasticsearch = $elasticsearch; @@ -586,6 +589,19 @@ public function withOpenapiContext(array $openapiContext): self return $self; } + public function getOpenapi(): ?bool + { + return $this->openapi; + } + + public function withOpenapi(bool $openapi): self + { + $self = clone $this; + $self->openapi = $openapi; + + return $self; + } + public function getExceptionToStatus(): ?array { return $this->exceptionToStatus; diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 59c05731a9d..4d0ac70758a 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -51,6 +51,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = false, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -106,5 +107,6 @@ public function __construct( $this->controller = $controller; $this->output = $output; $this->read = $read; + $this->openapi = $openapi; } } diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 1ec586fce53..f78335b3727 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index c041c2a1b7b..6637a431648 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 4ad7e902d08..94063c42995 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -43,6 +43,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, + ?bool $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index dfe9ebdeed5..18876171bce 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -146,6 +146,11 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + // Operation ignored from OpenApi + if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + continue; + } + $uriVariables = $operation->getUriVariables(); $resourceClass = $operation->getClass() ?? $resource->getClass(); $routeName = $operation->getRouteName() ?? $operation->getName(); @@ -402,6 +407,11 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection continue; } + // Operation ignored from OpenApi + if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + continue; + } + $operationUriVariables = $operation->getUriVariables(); foreach ($currentOperation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { if (!isset($operationUriVariables[$parameterName])) { diff --git a/tests/Metadata/Extractor/XmlExtractorTest.php b/tests/Metadata/Extractor/XmlExtractorTest.php index c04ae36ae69..e68881dd5b7 100644 --- a/tests/Metadata/Extractor/XmlExtractorTest.php +++ b/tests/Metadata/Extractor/XmlExtractorTest.php @@ -262,6 +262,7 @@ public function testValidXML(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, ], [ 'name' => null, @@ -357,6 +358,7 @@ public function testValidXML(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, ], ], 'graphQlOperations' => null, diff --git a/tests/Metadata/Extractor/YamlExtractorTest.php b/tests/Metadata/Extractor/YamlExtractorTest.php index 89927829ac1..776f1c7344c 100644 --- a/tests/Metadata/Extractor/YamlExtractorTest.php +++ b/tests/Metadata/Extractor/YamlExtractorTest.php @@ -299,6 +299,7 @@ public function testValidYaml(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, ], [ 'name' => null, @@ -375,6 +376,7 @@ public function testValidYaml(): void 'priority' => null, 'processor' => null, 'provider' => null, + 'openapi' => null, ], ], 'graphQlOperations' => null, From e0331d55e73d2b121b5802b3b202e7c5c00bb063 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Mon, 8 Aug 2022 10:38:00 +0200 Subject: [PATCH 03/12] fix: Swagger v2 generation --- src/Core/Swagger/Serializer/DocumentationNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Swagger/Serializer/DocumentationNormalizer.php b/src/Core/Swagger/Serializer/DocumentationNormalizer.php index ca74108fbc2..8e7a153511e 100644 --- a/src/Core/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Core/Swagger/Serializer/DocumentationNormalizer.php @@ -413,7 +413,7 @@ private function updateGetOperation(bool $v3, \ArrayObject $pathOperation, array $parametersMemory = []; $pathOperation['parameters'] = []; - foreach ($resourceMetadata->getAttributes()['identifiers'] as $parameterName => [$class, $identifier]) { + foreach ($resourceMetadata->getCollectionOperations()[$operationName]['identifiers'] as $parameterName => [$class, $identifier]) { $parameter = ['name' => $parameterName, 'in' => 'path', 'required' => true]; $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string'; $pathOperation['parameters'][] = $parameter; From 997ab6eb433f490bea3a4d4ef5bae253186c7742 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Tue, 9 Aug 2022 17:07:05 +0200 Subject: [PATCH 04/12] feat: add itemUriTemplate GetCollection and Post operations option --- features/hal/item_uri_template.feature | 128 +++++++++++ features/hydra/item_uri_template.feature | 131 ++++++++++++ features/jsonapi/item_uri_template.feature | 200 ++++++++++++++++++ phpstan.neon.dist | 1 + src/Hal/Serializer/ItemNormalizer.php | 3 +- src/Hydra/Serializer/CollectionNormalizer.php | 14 +- src/JsonApi/Serializer/ItemNormalizer.php | 3 +- .../Extractor/XmlResourceExtractor.php | 6 + .../Extractor/YamlResourceExtractor.php | 6 + src/Metadata/GetCollection.php | 19 +- src/Metadata/Post.php | 19 +- .../Resource/ResourceMetadataCollection.php | 26 ++- .../AbstractCollectionNormalizer.php | 20 +- src/Symfony/Bundle/Resources/config/hydra.xml | 1 + src/Symfony/Routing/IriConverter.php | 4 + tests/Fixtures/TestBundle/Model/Car.php | 32 +++ .../config/api_resources_v3/resources.yaml | 22 ++ .../TestBundle/State/CarProcessor.php | 30 +++ .../TestBundle/State/FakeProvider.php | 2 +- tests/Fixtures/app/config/config_common.yml | 5 + tests/Metadata/Extractor/XmlExtractorTest.php | 1 + .../Metadata/Extractor/YamlExtractorTest.php | 1 + 22 files changed, 661 insertions(+), 13 deletions(-) create mode 100644 features/hal/item_uri_template.feature create mode 100644 features/hydra/item_uri_template.feature create mode 100644 features/jsonapi/item_uri_template.feature create mode 100644 tests/Fixtures/TestBundle/Model/Car.php create mode 100644 tests/Fixtures/TestBundle/State/CarProcessor.php diff --git a/features/hal/item_uri_template.feature b/features/hal/item_uri_template.feature new file mode 100644 index 00000000000..5c949d07161 --- /dev/null +++ b/features/hal/item_uri_template.feature @@ -0,0 +1,128 @@ +@php8 +@v3 +Feature: Exposing a collection of objects should use the specified operation to generate the IRI + + Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/cars" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON HAL schema + And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["_links", "_embedded", "totalItems"], + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/cars$"}} + }, + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": {"href": {"pattern": "^/cars/.+$"}} + } + } + } + }, + "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, + "_embedded": { + "type": "object", + "properties": { + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/cars/.+$"}} + } + } + }, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ + + Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/brands/renault/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["_links", "_embedded", "totalItems"], + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/brands/renault/cars$"}} + }, + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} + } + } + } + }, + "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, + "_embedded": { + "type": "object", + "properties": { + "item": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "properties": { + "_links": { + "type": "object", + "properties": { + "self": { + "type": "object", + "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} + } + } + }, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ diff --git a/features/hydra/item_uri_template.feature b/features/hydra/item_uri_template.feature new file mode 100644 index 00000000000..4f615ccfaae --- /dev/null +++ b/features/hydra/item_uri_template.feature @@ -0,0 +1,131 @@ +@php8 +@v3 +Feature: Exposing a collection of objects should use the specified operation to generate the IRI + + Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation + When I send a "GET" request to "/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/cars$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation + When I send a "GET" request to "/brands/renault/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/brands/renault/cars$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@id": {"pattern": "^/brands/renault/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation + When I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/cars" with body: + """ + { + "owner": "Vincent" + } + """ + 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", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + """ + + Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation + When I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/brands/renault/cars" with body: + """ + { + "owner": "Vincent" + } + """ + 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", + "additionalProperties": false, + "required": ["@id", "@type", "id", "owner"], + "properties": { + "@context": {"pattern": "^/contexts/Car$"}, + "@id": {"pattern": "^/brands/renault/cars/.+$"}, + "@type": {"pattern": "^Car$"}, + "id": {"type": "string"}, + "owner": {"type": "string"} + } + } + """ diff --git a/features/jsonapi/item_uri_template.feature b/features/jsonapi/item_uri_template.feature new file mode 100644 index 00000000000..7dbfb223580 --- /dev/null +++ b/features/jsonapi/item_uri_template.feature @@ -0,0 +1,200 @@ +@php8 +@v3 +Feature: Exposing a collection of objects should use the specified operation to generate the IRI + + Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/cars" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON HAL schema + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["links", "meta", "data"], + "properties": { + "links": { + "type": "object", + "additionalProperties": false, + "required": ["self"], + "properties": { + "self": {"pattern": "^/cars$"} + } + }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["totalItems"], + "properties": { + "totalItems": {"type": "number", "minimum": 2, "maximum": 2} + } + }, + "data": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ + + Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/brands/renault/cars" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["links", "meta", "data"], + "properties": { + "links": { + "type": "object", + "additionalProperties": false, + "required": ["self"], + "properties": { + "self": {"pattern": "^/brands/renault/cars$"} + } + }, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["totalItems"], + "properties": { + "totalItems": {"type": "number", "minimum": 2, "maximum": 2} + } + }, + "data": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/brands/renault/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + } + """ + + Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation + When I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/cars" with body: + """ + { + "owner": "Vincent" + } + """ + 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/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + """ + + Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation + When I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/brands/renault/cars" with body: + """ + { + "owner": "Vincent" + } + """ + 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/vnd.api+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["data"], + "properties": { + "data": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "attributes"], + "properties": { + "id": {"pattern": "^/brands/renault/cars/.+$"}, + "type": {"pattern": "^Car$"}, + "attributes": { + "type": "object", + "additionalProperties": false, + "required": ["_id", "owner"], + "properties": { + "_id": {"type": "string"}, + "owner": {"type": "string"} + } + } + } + } + } + } + """ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 10526cd36a0..f06983adcd9 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -286,6 +286,7 @@ parameters: paths: - tests/Fixtures/TestBundle/Document/ - tests/Fixtures/TestBundle/Entity/ + - '#Call to an undefined method ApiPlatform\\Metadata\\.+::getItemUriTemplate\(\)\.#' - '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy<(\\?[a-zA-Z0-9_]+)+>::\$[a-zA-Z0-9_]+#' - message: '#Call to an undefined method Doctrine\\Persistence\\ObjectManager::getConnection\(\)#' diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index acc3586023b..551adbed391 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Hal\Serializer; +use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Core\Api\IriConverterInterface as LegacyIriConverterInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Metadata\ApiProperty; @@ -70,7 +71,7 @@ public function normalize($object, $format = null, array $context = []) } $context = $this->initContext($resourceClass, $context); - $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object); + $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['iri'] = $iri; $context['api_normalize'] = true; diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index ca2243f3e1c..d0302617869 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -20,6 +20,8 @@ use ApiPlatform\Core\Api\OperationType; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; @@ -46,11 +48,12 @@ final class CollectionNormalizer implements NormalizerInterface, NormalizerAware private $contextBuilder; private $resourceClassResolver; private $iriConverter; + private $resourceMetadataCollectionFactory; private $defaultContext = [ self::IRI_ONLY => false, ]; - public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, $iriConverter, array $defaultContext = []) + public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, $iriConverter, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, array $defaultContext = []) { $this->contextBuilder = $contextBuilder; $this->resourceClassResolver = $resourceClassResolver; @@ -59,6 +62,7 @@ public function __construct(ContextBuilderInterface $contextBuilder, ResourceCla trigger_deprecation('api-platform/core', '2.7', sprintf('Use an implementation of "%s" instead of "%s".', IriConverterInterface::class, LegacyIriConverterInterface::class)); } $this->iriConverter = $iriConverter; + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -95,7 +99,13 @@ public function normalize($object, $format = null, array $context = []): array $data['@type'] = 'hydra:Collection'; $data['hydra:member'] = []; $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; - unset($context['operation'], $context['operation_name'], $context['uri_variables']); + + if ($this->resourceMetadataCollectionFactory && ($operation = $context['operation'] ?? null) instanceof CollectionOperationInterface && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->matchOperation($itemUriTemplate); + } else { + unset($context['operation']); + } + unset($context['operation_name'], $context['uri_variables']); foreach ($object as $obj) { if ($iriOnly) { diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index ca8184274af..024c9f3a9ae 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Api\ResourceClassResolverInterface; +use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Core\Api\IriConverterInterface as LegacyIriConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -86,7 +87,7 @@ public function normalize($object, $format = null, array $context = []) } $context = $this->initContext($resourceClass, $context); - $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object); + $iri = $this->iriConverter instanceof LegacyIriConverterInterface ? $this->iriConverter->getIriFromItem($object) : $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $context['iri'] = $iri; $context['api_normalize'] = true; diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 5c52cdeeb1b..a13ae2df583 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -14,9 +14,11 @@ namespace ApiPlatform\Metadata\Extractor; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\Post; use Symfony\Component\Config\Util\XmlUtils; /** @@ -294,6 +296,10 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr } } + if (\in_array((string) $operation['class'], [GetCollection::class, Post::class], true)) { + $datum['itemUriTemplate'] = $this->phpize($operation, 'itemUriTemplate', 'string'); + } + $data[] = array_merge($datum, [ 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'collection' => $this->phpize($operation, 'collection', 'bool'), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index dabcc4eaf92..04539a6360a 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -14,11 +14,13 @@ namespace ApiPlatform\Metadata\Extractor; use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GraphQl\DeleteMutation; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\Post; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -265,6 +267,10 @@ private function buildOperations(array $resource, array $root): ?array } } + if (\in_array((string) $class, [GetCollection::class, Post::class], true)) { + $datum['itemUriTemplate'] = $this->phpize($operation, 'itemUriTemplate', 'string'); + } + $data[] = array_merge($datum, [ 'read' => $this->phpize($operation, 'read', 'bool'), 'deserialize' => $this->phpize($operation, 'deserialize', 'bool'), diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index dacd5745084..b35d1ed4004 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -16,6 +16,8 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class GetCollection extends HttpOperation implements CollectionOperationInterface { + private $itemUriTemplate; + /** * {@inheritdoc} */ @@ -91,8 +93,23 @@ public function __construct( ?string $name = null, $provider = null, $processor = null, - array $extraProperties = [] + array $extraProperties = [], + ?string $itemUriTemplate = null ) { parent::__construct(self::METHOD_GET, ...\func_get_args()); + $this->itemUriTemplate = $itemUriTemplate; + } + + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(string $itemUriTemplate): self + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; } } diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 6637a431648..3a920671f61 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -16,6 +16,8 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Post extends HttpOperation { + private $itemUriTemplate; + /** * {@inheritdoc} */ @@ -92,8 +94,23 @@ public function __construct( ?string $name = null, $provider = null, $processor = null, - array $extraProperties = [] + array $extraProperties = [], + ?string $itemUriTemplate = null ) { parent::__construct(self::METHOD_POST, ...\func_get_args()); + $this->itemUriTemplate = $itemUriTemplate; + } + + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(string $itemUriTemplate): self + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; } } diff --git a/src/Metadata/Resource/ResourceMetadataCollection.php b/src/Metadata/Resource/ResourceMetadataCollection.php index e5f9652c9c3..86f4a5a7b12 100644 --- a/src/Metadata/Resource/ResourceMetadataCollection.php +++ b/src/Metadata/Resource/ResourceMetadataCollection.php @@ -49,7 +49,7 @@ public function getOperation(?string $operationName = null, bool $forceCollectio $metadata = null; while ($it->valid()) { - /** @var ApiResource */ + /** @var ApiResource $metadata */ $metadata = $it->current(); foreach ($metadata->getOperations() ?? [] as $name => $operation) { @@ -80,6 +80,30 @@ public function getOperation(?string $operationName = null, bool $forceCollectio $this->handleNotFound($operationName, $metadata); } + /** + * Find a Get operation which matches the uriTemplate. + */ + public function matchOperation(string $uriTemplate): Operation + { + $it = $this->getIterator(); + $metadata = null; + + while ($it->valid()) { + /** @var ApiResource $metadata */ + $metadata = $it->current(); + + foreach ($metadata->getOperations() ?? [] as $operation) { + if ($uriTemplate === $operation->getUriTemplate() && HttpOperation::METHOD_GET === $operation->getMethod() && !$operation instanceof CollectionOperationInterface) { + return $operation; + } + } + + $it->next(); + } + + $this->handleNotFound($uriTemplate, $metadata); + } + /** * @throws OperationNotFoundException */ diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index d91fe5ff0b4..e96c1f2945e 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -15,7 +15,10 @@ use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; @@ -87,12 +90,19 @@ public function normalize($object, $format = null, array $context = []) $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']); $context = $this->initContext($resourceClass, $context); $data = []; + $paginationData = $this->getPaginationData($object, $context); + + /** @var ResourceMetadata|ResourceMetadataCollection */ + $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); + if ($metadata instanceof ResourceMetadataCollection && ($operation = $context['operation'] ?? null) instanceof CollectionOperationInterface && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $context['operation'] = $metadata->matchOperation($itemUriTemplate); + } else { + unset($context['operation']); + } + unset($context['operation_type'], $context['operation_name']); + $itemsData = $this->getItemsData($object, $format, $context); - return array_merge_recursive( - $data, - $this->getPaginationData($object, $context), - $this->getItemsData($object, $format, $context) - ); + return array_merge_recursive($data, $paginationData, $itemsData); } /** diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 6710e7b7eb4..bdd2351d42e 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -56,6 +56,7 @@ + diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 73dfae69151..a45cdeb24ac 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -134,6 +134,10 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter // Custom resources should have the same IRI as requested, it was not the case pre 2.7 $isLegacyCustomResource = ($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false) && ($operation->getExtraProperties()['user_defined_uri_template'] ?? false); + if ($operation instanceof HttpOperation && HttpOperation::METHOD_POST === $operation->getMethod() && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->matchOperation($itemUriTemplate); + } + // In symfony the operation name is the route name, try to find one if none provided if ( !$operation->getName() diff --git a/tests/Fixtures/TestBundle/Model/Car.php b/tests/Fixtures/TestBundle/Model/Car.php new file mode 100644 index 00000000000..5d3100b7a45 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/Car.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\Model; + +/** + * Class with multiple resources, each with a GetCollection, a Get and a Post operations. + * Using itemUriTemplate on GetCollection and Post operations should specify which operation to use to generate the IRI. + * + * @author Vincent Chalamon + */ +class Car +{ + public $id; + public $owner; + + public function __construct($id = null, $owner = null) + { + $this->id = $id; + $this->owner = $owner; + } +} diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml index 88ffb054bd3..7cc0d4c3b1b 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml @@ -62,3 +62,25 @@ resources: provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider ApiPlatform\Tests\Fixtures\TestBundle\Entity\FlexConfig: ~ + + ApiPlatform\Tests\Fixtures\TestBundle\Model\Car: + - graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + ApiPlatform\Metadata\Post: + processor: ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor + ApiPlatform\Metadata\Get: ~ + - graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + uriTemplate: /brands/renault/cars + itemUriTemplate: /brands/renault/cars/{id} + ApiPlatform\Metadata\Post: + processor: ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor + uriTemplate: /brands/renault/cars + itemUriTemplate: /brands/renault/cars/{id} + ApiPlatform\Metadata\Get: + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider + uriTemplate: /brands/renault/cars/{id} diff --git a/tests/Fixtures/TestBundle/State/CarProcessor.php b/tests/Fixtures/TestBundle/State/CarProcessor.php new file mode 100644 index 00000000000..f2e5e4c1e4a --- /dev/null +++ b/tests/Fixtures/TestBundle/State/CarProcessor.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; + +class CarProcessor implements ProcessorInterface +{ + /** + * {@inheritdoc} + */ + public function process($data, Operation $operation, array $uriVariables = [], array $context = []) + { + $data->id = (string) random_int(1, 10); + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/State/FakeProvider.php b/tests/Fixtures/TestBundle/State/FakeProvider.php index 158e67afbbf..34bc7600383 100644 --- a/tests/Fixtures/TestBundle/State/FakeProvider.php +++ b/tests/Fixtures/TestBundle/State/FakeProvider.php @@ -31,7 +31,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c '67890' => new $className('67890', 'Grégoire'), ]; - if ($uriVariables) { + if (isset($uriVariables['id'])) { return $data[$uriVariables['id']] ?? null; } diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index c1410666638..093dcc17f6c 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -139,6 +139,11 @@ services: tags: - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor' + tags: + - { name: 'api_platform.state_processor' } + ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider' public: false diff --git a/tests/Metadata/Extractor/XmlExtractorTest.php b/tests/Metadata/Extractor/XmlExtractorTest.php index e68881dd5b7..dc1f9b45e8f 100644 --- a/tests/Metadata/Extractor/XmlExtractorTest.php +++ b/tests/Metadata/Extractor/XmlExtractorTest.php @@ -263,6 +263,7 @@ public function testValidXML(): void 'processor' => null, 'provider' => null, 'openapi' => null, + 'itemUriTemplate' => null, ], [ 'name' => null, diff --git a/tests/Metadata/Extractor/YamlExtractorTest.php b/tests/Metadata/Extractor/YamlExtractorTest.php index 776f1c7344c..843df41f723 100644 --- a/tests/Metadata/Extractor/YamlExtractorTest.php +++ b/tests/Metadata/Extractor/YamlExtractorTest.php @@ -300,6 +300,7 @@ public function testValidYaml(): void 'processor' => null, 'provider' => null, 'openapi' => null, + 'itemUriTemplate' => null, ], [ 'name' => null, From 02fbc3c64fbb7e1b3a74723e165f39b495c174cc Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Tue, 9 Aug 2022 19:51:42 +0200 Subject: [PATCH 05/12] fix: doctrine compatibility --- .github/workflows/ci.yml | 8 ++++++++ phpstan.neon.dist | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9215dce42..e9a1ceaddf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,10 @@ jobs: composer remove --dev --no-interaction --no-progress --no-update --ansi \ doctrine/mongodb-odm \ doctrine/mongodb-odm-bundle + # https://github.com/doctrine/dbal/issues/5570 + - name: Fix Doctrine dependencies + if: (startsWith(matrix.php, '7.1') || startsWith(matrix.php, '7.2') || startsWith(matrix.php, '7.3')) + run: composer require "doctrine/orm:<2.13" --dev --no-interaction --no-progress --ansi - name: Update project dependencies run: composer update --no-interaction --no-progress --ansi - name: Require Symfony components @@ -230,6 +234,10 @@ jobs: doctrine/mongodb-odm-bundle - name: Update project dependencies run: composer update --no-interaction --no-progress --ansi + # https://github.com/doctrine/dbal/issues/5570 + - name: Fix Doctrine dependencies + if: (startsWith(matrix.php, '7.1') || startsWith(matrix.php, '7.2') || startsWith(matrix.php, '7.3')) + run: composer require "doctrine/orm:<2.13" --dev --no-interaction --no-progress --ansi - name: Require Symfony components if: (!startsWith(matrix.php, '7.1')) run: composer require symfony/uid --dev --no-interaction --no-progress --ansi diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f06983adcd9..4b0b14b4298 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -291,6 +291,9 @@ parameters: - message: '#Call to an undefined method Doctrine\\Persistence\\ObjectManager::getConnection\(\)#' path: src/Core/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php + - + message: '#Property Doctrine\\ORM\\Mapping\\ClassMetadataInfo<.+>::\$associationMappings .+ does not accept array#' + path: tests/Doctrine/ # https://github.com/willdurand/Negotiation/issues/89#issuecomment-513283286 - message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\)\.#' From bdcf7fd0f9f70ae55a7baeb8c6a9f97d454fe35f Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Wed, 10 Aug 2022 12:58:59 +0200 Subject: [PATCH 06/12] feat: handle output resource with GetCollection and Skolem IRI --- features/jsonld/input_output.feature | 127 +++++++++++++++++- src/Serializer/AbstractItemNormalizer.php | 7 +- .../Dto/DummyCollectionDtoOutput.php | 33 +++++ .../Dto/DummyIdCollectionDtoOutput.php | 29 ++++ .../TestBundle/Model/DummyCollectionDto.php | 27 ++++ .../Model/DummyFooCollectionDto.php | 27 ++++ .../TestBundle/Model/DummyIdCollectionDto.php | 29 ++++ .../config/api_resources_v3/resources.yaml | 24 ++++ .../State/DummyCollectionDtoProvider.php | 43 ++++++ tests/Fixtures/app/config/config_common.yml | 6 + 10 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.php create mode 100644 tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php create mode 100644 tests/Fixtures/TestBundle/Model/DummyCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index ad7ee0344e8..3acdae05d48 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -321,7 +321,7 @@ Feature: JSON-LD DTO input and output """ @createSchema - Scenario: Initialize input data with a DataTransformerInitializer + Scenario: Initialize input data with a DataTransformerInitializer Given there is an InitializeInput object with id 1 When I send a "PUT" request to "/initialize_inputs/1" with body: """ @@ -348,7 +348,7 @@ Feature: JSON-LD DTO input and output """ { "foo": "test", - "bar": "test" + "bar": "test" } """ Then the response status code should be 400 @@ -356,7 +356,7 @@ Feature: JSON-LD DTO input and output And the JSON node "hydra:description" should be equal to "The input data is misformatted." @!mongodb - Scenario: Reset password through an input DTO without DataTransformer + Scenario: Reset password through an input DTO without DataTransformer When I send a "POST" request to "/user-reset-password" with body: """ { @@ -369,7 +369,7 @@ Feature: JSON-LD DTO input and output And the JSON node "email" should be equal to "user@example.com" @!mongodb - Scenario: Reset password with an invalid payload through an input DTO without DataTransformer + Scenario: Reset password with an invalid payload through an input DTO without DataTransformer And I send a "POST" request to "/user-reset-password" with body: """ { @@ -378,3 +378,122 @@ Feature: JSON-LD DTO input and output """ Then the response status code should be 422 And the response should be in JSON + + @v3 + Scenario: Get a collection with a custom output and without item operations, from a resource without identifier + When I send a "GET" request to "/dummy_collection_dtos" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/DummyCollectionDto$"}, + "@id": {"pattern": "^/dummy_collection_dtos$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "foo", "bar"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^DummyCollectionDto$"}, + "foo": {"type": "string"}, + "bar": {"type": "integer"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + @v3 + # Cannot generate proper IRI because DTO does not support resource yet + # todo Change member IRI to `/dummy_foos/bar` once DTO support resource + Scenario: Get a collection with a custom output and itemUriTemplate, from a resource without identifier + When I send a "GET" request to "/dummy_foo_collection_dtos" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/DummyFooCollectionDto$"}, + "@id": {"pattern": "^/dummy_foo_collection_dtos$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "foo", "bar"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^DummyFooCollectionDto$"}, + "foo": {"type": "string"}, + "bar": {"type": "integer"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ + + @v3 + # Cannot generate proper IRI because DTO does not support output yet + # todo Change member IRI to `/dummy_id_collection_dtos/.+` once DTO support @ApiProperty + Scenario: Get a collection with a custom output and without item operations, from a resource with an identifier + When I send a "GET" request to "/dummy_id_collection_dtos" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "additionalProperties": false, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], + "properties": { + "@context": {"pattern": "^/contexts/DummyIdCollectionDto$"}, + "@id": {"pattern": "^/dummy_id_collection_dtos$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["@id", "@type", "id", "foo", "bar"], + "properties": { + "@id": {"pattern": "^/.well-known/genid/.+$"}, + "@type": {"pattern": "^DummyIdCollectionDto$"}, + "id": {"type": "integer"}, + "foo": {"type": "string"}, + "bar": {"type": "integer"} + } + } + }, + "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} + } + } + """ diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 12734a5e28a..279edf8fd3a 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -188,9 +188,10 @@ public function normalize($object, $format = null, array $context = []) return $this->serializer->normalize($transformed, $format, $context); } - unset($context['output']); - unset($context['operation']); - unset($context['operation_name']); + unset($context['output'], $context['operation_name']); + if ($this->resourceMetadataFactory instanceof ResourceMetadataCollectionFactoryInterface && !isset($context['operation'])) { + $context['operation'] = $this->resourceMetadataFactory->create($context['resource_class'])->getOperation(); + } $context['resource_class'] = $outputClass; $context['api_sub_level'] = true; $context[self::ALLOW_EXTRA_ATTRIBUTES] = false; diff --git a/tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.php b/tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.php new file mode 100644 index 00000000000..119584c5ded --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/DummyCollectionDtoOutput.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\Dto; + +class DummyCollectionDtoOutput +{ + /** + * @var string + */ + public $foo; + + /** + * @var int + */ + public $bar; + + public function __construct(string $foo, int $bar) + { + $this->foo = $foo; + $this->bar = $bar; + } +} diff --git a/tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php b/tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php new file mode 100644 index 00000000000..68e86959c7e --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/DummyIdCollectionDtoOutput.php @@ -0,0 +1,29 @@ + + * + * For 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\Dto; + +class DummyIdCollectionDtoOutput extends DummyCollectionDtoOutput +{ + /** + * @var int + */ + public $id; + + public function __construct(int $id, string $foo, int $bar) + { + parent::__construct($foo, $bar); + + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/Model/DummyCollectionDto.php b/tests/Fixtures/TestBundle/Model/DummyCollectionDto.php new file mode 100644 index 00000000000..12de88fbb1f --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/DummyCollectionDto.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\Tests\Fixtures\TestBundle\Model; + +/** + * Dummy resource without identifier, with a GetCollection with an output, and without any other operations. + */ +class DummyCollectionDto +{ + public $text; + + public function __construct($text) + { + $this->text = $text; + } +} diff --git a/tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.php b/tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.php new file mode 100644 index 00000000000..a100722951d --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/DummyFooCollectionDto.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\Tests\Fixtures\TestBundle\Model; + +/** + * Dummy resource without identifier, with a GetCollection with an output and itemUriTemplate. + */ +class DummyFooCollectionDto +{ + public $text; + + public function __construct($text) + { + $this->text = $text; + } +} diff --git a/tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php b/tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php new file mode 100644 index 00000000000..229dc1a31cf --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/DummyIdCollectionDto.php @@ -0,0 +1,29 @@ + + * + * For 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\Model; + +/** + * Dummy resource with an identifier, with a GetCollection with an output, and without any other operations. + */ +class DummyIdCollectionDto extends DummyCollectionDto +{ + public $id; + + public function __construct($id, $text) + { + parent::__construct($text); + + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml index 7cc0d4c3b1b..1e84954e151 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_v3/resources.yaml @@ -84,3 +84,27 @@ resources: ApiPlatform\Metadata\Get: provider: ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider uriTemplate: /brands/renault/cars/{id} + + ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyCollectionDto: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + output: ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyCollectionDtoOutput + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider + + ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyFooCollectionDto: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + output: ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyCollectionDtoOutput + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider + itemUriTemplate: /dummy_foos/bar + ApiPlatform\Metadata\Get: + uriTemplate: /dummy_foos/bar + + ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyIdCollectionDto: + graphQlOperations: [ ] + operations: + ApiPlatform\Metadata\GetCollection: + output: ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyIdCollectionDtoOutput + provider: ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider diff --git a/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php b/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php new file mode 100644 index 00000000000..7aa5703655e --- /dev/null +++ b/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.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\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyCollectionDtoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\DummyIdCollectionDtoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyFooCollectionDto; + +class DummyCollectionDtoProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $class = $operation->getOutput()['class']; + switch ($class) { + case DummyCollectionDtoOutput::class: + case DummyFooCollectionDto::class: + return [ + new $class('lorem', 1), + new $class('ipsum', 2), + ]; + case DummyIdCollectionDtoOutput::class: + return [ + new $class(1, 'lorem', 1), + new $class(2, 'ipsum', 2), + ]; + default: + return []; + } + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 093dcc17f6c..21568e81d20 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -107,6 +107,12 @@ services: tags: - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider' + public: false + tags: + - { name: 'api_platform.state_provider' } + # related_questions.state_provider: # class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\RelatedQuestionsProvider' # public: false From ef94bcf0145ef71a5ad946b5d79cfd9686a1a8c0 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 31 Aug 2022 15:45:12 +0200 Subject: [PATCH 07/12] fix: use uriTemplate instead of the route name The route name is very Symfony specific, let's use `uriTemplate` instead. The skolem id generation was moved to the IriConverter and will be in another class in a later commit. --- src/JsonApi/Serializer/ObjectNormalizer.php | 5 ++- src/JsonLd/ContextBuilder.php | 15 ++++++--- ...ationResourceMetadataCollectionFactory.php | 17 ++++++---- src/OpenApi/Factory/OpenApiFactory.php | 10 ------ .../Bundle/Resources/config/jsonld.xml | 2 ++ src/Symfony/Routing/ApiLoader.php | 4 +++ src/Symfony/Routing/IriConverter.php | 17 ++++------ src/Test/DoctrineMongoDbOdmTestCase.php | 1 - src/Util/SkolemTrait.php | 32 ------------------- 9 files changed, 37 insertions(+), 66 deletions(-) delete mode 100644 src/Util/SkolemTrait.php diff --git a/src/JsonApi/Serializer/ObjectNormalizer.php b/src/JsonApi/Serializer/ObjectNormalizer.php index eff6f44a038..640439ac2de 100644 --- a/src/JsonApi/Serializer/ObjectNormalizer.php +++ b/src/JsonApi/Serializer/ObjectNormalizer.php @@ -21,7 +21,6 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Util\ClassInfoTrait; -use ApiPlatform\Util\SkolemTrait; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -32,7 +31,6 @@ final class ObjectNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface { use ClassInfoTrait; - use SkolemTrait; public const FORMAT = 'jsonapi'; @@ -103,8 +101,9 @@ public function normalize($object, $format = null, array $context = []) 'type' => $this->getResourceShortName($resourceClass), ]; } else { + // Not using an IriConverter here is deprecated in 2.7, avoid spl_object_hash as it may collide $resourceData = [ - 'id' => $this->generateSkolemIri($object), + 'id' => $this->iriConverter instanceof LegacyIriConverterInterface ? '/.well-known/genid/'.bin2hex(random_bytes(10)) : $this->iriConverter->getIriFromResource($object), 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), ]; } diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 7ebd03523d3..ea04f293d3f 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonLd; +use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface as LegacyPropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface as LegacyPropertyNameCollectionFactoryInterface; @@ -25,7 +26,6 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Util\ClassInfoTrait; -use ApiPlatform\Util\SkolemTrait; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -37,7 +37,6 @@ final class ContextBuilder implements AnonymousContextBuilderInterface { use ClassInfoTrait; - use SkolemTrait; public const FORMAT = 'jsonld'; @@ -61,7 +60,9 @@ final class ContextBuilder implements AnonymousContextBuilderInterface */ private $nameConverter; - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null) + private $iriConverter; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null, IriConverterInterface $iriConverter = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -69,6 +70,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->propertyMetadataFactory = $propertyMetadataFactory; $this->urlGenerator = $urlGenerator; $this->nameConverter = $nameConverter; + $this->iriConverter = $iriConverter; if ($resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) { trigger_deprecation('api-platform/core', '2.7', sprintf('Use "%s" instead of "%s".', ResourceMetadataCollectionFactoryInterface::class, ResourceMetadataFactoryInterface::class)); @@ -190,7 +192,12 @@ public function getAnonymousResourceContext($object, array $context = [], int $r ]; if (!isset($context['iri']) || false !== $context['iri']) { - $jsonLdContext['@id'] = $context['iri'] ?? $this->generateSkolemIri($object); + // Not using an IriConverter here is deprecated in 2.7, avoid spl_object_hash as it may collide + if (isset($this->iriConverter)) { + $jsonLdContext['@id'] = $context['iri'] ?? $this->iriConverter->getIriFromResource($object); + } else { + $jsonLdContext['@id'] = $context['iri'] ?? '/.well-known/genid/'.bin2hex(random_bytes(10)); + } } if ($context['has_context'] ?? false) { diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php index 21b1bf69100..623a61dc360 100644 --- a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -16,8 +16,11 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Symfony\Routing\IriConverter; /** * Adds a {@see NotExposed} operation with {@see NotFoundAction} on a resource which only has a GetCollection. @@ -52,8 +55,8 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } + /** @var ApiResource $resource */ foreach ($resourceMetadataCollection as $resource) { - /** @var ApiResource $resource */ $operations = $resource->getOperations(); foreach ($operations as $operation) { @@ -69,12 +72,14 @@ public function create(string $resourceClass): ResourceMetadataCollection // No item operation has been found on all resources for resource class: generate one on the last resource // Helpful to generate an IRI for a resource without declaring the Get operation - // @phpstan-ignore-next-line - $operation = (new NotExposed())->withResource($resource)->withUriTemplate(null); // force uriTemplate to null to don't inherit it from resource - if (!$this->linkFactory->createLinksFromIdentifiers($resource)) { // @phpstan-ignore-line - $operation = $operation->withRouteName('api_genid'); + /** @var HttpOperation $operation */ + $operation = (new NotExposed())->withClass($resource->getClass())->withShortName($resource->getShortName()); // @phpstan-ignore-line $resource is defined if count > 0 + + if (!$this->linkFactory->createLinksFromIdentifiers($operation)) { + $operation = $operation->withUriTemplate(IriConverter::$skolemUriTemplate); } - $operations->add(sprintf('_api_%s_get', $resource->getShortName()), $operation)->sort(); // @phpstan-ignore-line + + $operations->add(sprintf('_api_%s_get', $operation->getShortName()), $operation)->sort(); // @phpstan-ignore-line $operations exists return $resourceMetadataCollection; } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 18876171bce..b435d991b34 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -141,11 +141,6 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - // Skolem IRI - if ('api_genid' === $operation->getRouteName()) { - continue; - } - // Operation ignored from OpenApi if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { continue; @@ -402,11 +397,6 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection continue; } - // Skolem IRI - if ('api_genid' === $operation->getRouteName()) { - continue; - } - // Operation ignored from OpenApi if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { continue; diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index a7437a579cb..f6a18110aa5 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -11,6 +11,8 @@ + null + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index 07eba2e7151..a6ab34b1300 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -111,6 +111,10 @@ public function load($data, $type = null): RouteCollection continue; } + if ($operation->getUriTemplate() === IriConverter::$skolemUriTemplate) { + continue; + } + $legacyDefaults = []; if ($operation->getExtraProperties()['is_legacy_subresource'] ?? false) { diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index a45cdeb24ac..81e89141336 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -48,6 +48,8 @@ final class IriConverter implements IriConverterInterface use ResourceClassInfoTrait; use UriVariablesResolverTrait; + public static $skolemUriTemplate = '/.well-known/genid/{id}'; + private $provider; private $router; private $identifiersExtractor; @@ -158,6 +160,11 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter $identifiers = []; } + if (!$operation->getName() || ($operation instanceof HttpOperation && self::$skolemUriTemplate === $operation->getUriTemplate())) { + // Use a skolem iri, the route is defined in genid.xml random bytes as a hash map + virer les operation name == uri template sauf en interne dans symfony + return $this->router->generate('api_genid', ['id' => \is_object($item) ? spl_object_hash($item) : bin2hex(random_bytes(10))], $operation->getUrlGenerationStrategy() ?? $referenceType); + } + if (\is_object($item)) { try { $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item, $operation); @@ -169,16 +176,6 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter } } - if (!$operation->getName()) { - // Generate Skolem uri for unnamed operation - $operation = $operation->withName('api_genid'); - } - - if ('api_genid' === $operation->getName()) { - // If $item is not an object (can be a class name), generate a random id - $identifiers = ['id' => \is_object($item) ? spl_object_hash($item) : bin2hex(random_bytes(10))]; - } - try { return $this->router->generate($operation->getName(), $identifiers, $operation->getUrlGenerationStrategy() ?? $referenceType); } catch (RoutingExceptionInterface $e) { diff --git a/src/Test/DoctrineMongoDbOdmTestCase.php b/src/Test/DoctrineMongoDbOdmTestCase.php index b2bec59a0f2..71f613c9dd1 100644 --- a/src/Test/DoctrineMongoDbOdmTestCase.php +++ b/src/Test/DoctrineMongoDbOdmTestCase.php @@ -20,7 +20,6 @@ use Doctrine\ODM\MongoDB\Mapping\Driver\AnnotationDriver; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; - use function sys_get_temp_dir; /** diff --git a/src/Util/SkolemTrait.php b/src/Util/SkolemTrait.php deleted file mode 100644 index add0aebb588..00000000000 --- a/src/Util/SkolemTrait.php +++ /dev/null @@ -1,32 +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\Util; - -/** - * Generates a Skolem IRI. - * - * @internal - * - * @author Vincent Chalamon - */ -trait SkolemTrait -{ - /** - * @param object $object - */ - private function generateSkolemIri($object): string - { - return '/.well-known/genid/'.spl_object_hash($object); - } -} From 3cdebc75b41a8ad5c9496fe2add23ac3048b7070 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 31 Aug 2022 16:34:01 +0200 Subject: [PATCH 08/12] feat(iri): move skolem iri to another class Note that we still put this inside the Symfony class as we use the router for the url GenerationStrategy feature which is for now tight to the Symfony router --- ...ationResourceMetadataCollectionFactory.php | 4 +- src/Symfony/Bundle/Resources/config/api.xml | 6 +- .../Bundle/Resources/config/jsonld.xml | 2 +- src/Symfony/Routing/ApiLoader.php | 2 +- src/Symfony/Routing/IriConverter.php | 19 +++-- src/Symfony/Routing/SkolemIriConverter.php | 75 +++++++++++++++++++ tests/Symfony/Routing/IriConverterTest.php | 8 +- 7 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 src/Symfony/Routing/SkolemIriConverter.php diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php index 623a61dc360..1cb446df830 100644 --- a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -20,7 +20,7 @@ use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\Symfony\Routing\IriConverter; +use ApiPlatform\Symfony\Routing\SkolemIriConverter; /** * Adds a {@see NotExposed} operation with {@see NotFoundAction} on a resource which only has a GetCollection. @@ -76,7 +76,7 @@ public function create(string $resourceClass): ResourceMetadataCollection $operation = (new NotExposed())->withClass($resource->getClass())->withShortName($resource->getShortName()); // @phpstan-ignore-line $resource is defined if count > 0 if (!$this->linkFactory->createLinksFromIdentifiers($operation)) { - $operation = $operation->withUriTemplate(IriConverter::$skolemUriTemplate); + $operation = $operation->withUriTemplate(SkolemIriConverter::$skolemUriTemplate); } $operations->add(sprintf('_api_%s_get', $operation->getShortName()), $operation)->sort(); // @phpstan-ignore-line $operations exists diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index c5adc580b90..3a8e1e5020f 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -142,10 +142,14 @@ - null + + + + + diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index f6a18110aa5..38042c0b34e 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -12,7 +12,7 @@ null - + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index a6ab34b1300..51e7a428fe5 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -111,7 +111,7 @@ public function load($data, $type = null): RouteCollection continue; } - if ($operation->getUriTemplate() === IriConverter::$skolemUriTemplate) { + if ($operation->getUriTemplate() === SkolemIriConverter::$skolemUriTemplate) { continue; } diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 81e89141336..26444801e8b 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -48,14 +48,13 @@ final class IriConverter implements IriConverterInterface use ResourceClassInfoTrait; use UriVariablesResolverTrait; - public static $skolemUriTemplate = '/.well-known/genid/{id}'; - private $provider; private $router; private $identifiersExtractor; private $resourceMetadataCollectionFactory; + private $decorated; - public function __construct(ProviderInterface $provider, RouterInterface $router, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, UriVariablesConverterInterface $uriVariablesConverter = null) + public function __construct(ProviderInterface $provider, RouterInterface $router, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, UriVariablesConverterInterface $uriVariablesConverter = null, IriConverterInterface $decorated = null) { $this->provider = $provider; $this->router = $router; @@ -65,6 +64,7 @@ public function __construct(ProviderInterface $provider, RouterInterface $router // For the ResourceClassInfoTrait $this->resourceClassResolver = $resourceClassResolver; $this->resourceMetadataFactory = $resourceMetadataCollectionFactory; + $this->decorated = $decorated; } /** @@ -136,8 +136,9 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter // Custom resources should have the same IRI as requested, it was not the case pre 2.7 $isLegacyCustomResource = ($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false) && ($operation->getExtraProperties()['user_defined_uri_template'] ?? false); - if ($operation instanceof HttpOperation && HttpOperation::METHOD_POST === $operation->getMethod() && ($itemUriTemplate = $operation->getItemUriTemplate())) { - $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->matchOperation($itemUriTemplate); + // FIXME: to avoid the method_exists we could create an interface for the Post operation, we can't guarantee that the user extended our ApiPlatform\Metadata\Post + if ($operation instanceof HttpOperation && HttpOperation::METHOD_POST === $operation->getMethod() && method_exists($operation, 'getItemUriTemplate') && ($itemUriTemplate = $operation->getItemUriTemplate())) { + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($itemUriTemplate); } // In symfony the operation name is the route name, try to find one if none provided @@ -160,9 +161,13 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter $identifiers = []; } - if (!$operation->getName() || ($operation instanceof HttpOperation && self::$skolemUriTemplate === $operation->getUriTemplate())) { + if (!$operation->getName() || ($operation instanceof HttpOperation && SkolemIriConverter::$skolemUriTemplate === $operation->getUriTemplate())) { + if (!$this->decorated) { + throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', $resourceClass), $e->getCode(), $e); + } + // Use a skolem iri, the route is defined in genid.xml random bytes as a hash map + virer les operation name == uri template sauf en interne dans symfony - return $this->router->generate('api_genid', ['id' => \is_object($item) ? spl_object_hash($item) : bin2hex(random_bytes(10))], $operation->getUrlGenerationStrategy() ?? $referenceType); + return $this->decorated->getIriFromResource($item, $referenceType = UrlGeneratorInterface::ABS_PATH, $operation, $context); } if (\is_object($item)) { diff --git a/src/Symfony/Routing/SkolemIriConverter.php b/src/Symfony/Routing/SkolemIriConverter.php new file mode 100644 index 00000000000..e3b15179d35 --- /dev/null +++ b/src/Symfony/Routing/SkolemIriConverter.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\Symfony\Routing; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Operation; +use SplObjectStorage; +use Symfony\Component\Routing\RouterInterface; + +/** + * {@inheritdoc} + * + * @experimental + * + * @author Antoine Bluchet + */ +final class SkolemIriConverter implements IriConverterInterface +{ + public static $skolemUriTemplate = '/.well-known/genid/{id}'; + + private $objectHashMap; + private $classHashMap = []; + private $router; + + public function __construct(RouterInterface $router) + { + $this->router = $router; + $this->objectHashMap = new SplObjectStorage(); + } + + /** + * {@inheritdoc} + */ + public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null) + { + throw new ItemNotFoundException(sprintf('Item not found for "%s".', $iri)); + } + + /** + * {@inheritdoc} + */ + public function getIriFromResource($item, int $referenceType = UrlGeneratorInterface::ABS_PATH, Operation $operation = null, array $context = []): ?string + { + if (($isObject = \is_object($item)) && $this->objectHashMap->contains($item)) { + return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$item]], $operation?->getUrlGenerationStrategy() ?? $referenceType); + } + + if (\is_string($item) && isset($this->classHashMap[$item])) { + return $this->router->generate('api_genid', ['id' => $this->classHashMap[$item]], $operation?->getUrlGenerationStrategy() ?? $referenceType); + } + + $id = bin2hex(random_bytes(10)); + + if ($isObject) { + $this->objectHashMap[$item] = $id; + } else { + $this->classHashMap[$item] = $id; + } + + return $this->router->generate('api_genid', ['id' => $id], $operation?->getUrlGenerationStrategy() ?? $referenceType); + } +} diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index 1adaf61ff25..96453c9b60d 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -30,6 +30,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\ProviderInterface; use ApiPlatform\Symfony\Routing\IriConverter; +use ApiPlatform\Symfony\Routing\SkolemIriConverter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -166,10 +167,11 @@ public function testGetGenidIriFromUnnamedOperation() $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->generate('api_genid', Argument::type('array'), UrlGeneratorInterface::ABS_PATH)->shouldBeCalled()->willReturn($route); + $skolemIriConverter = new SkolemIriConverter($routerProphecy->reveal()); $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [])); - $iriConverter = $this->getIriConverter(null, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy); + $iriConverter = $this->getIriConverter(null, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy, null, $skolemIriConverter); $this->assertEquals($route, $iriConverter->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation)); } @@ -281,7 +283,7 @@ private function getResourceClassResolver() return $resourceClassResolver->reveal(); } - private function getIriConverter($stateProviderProphecy = null, $routerProphecy = null, $identifiersExtractorProphecy = null, $resourceMetadataCollectionFactoryProphecy = null, $uriVariablesConverter = null) + private function getIriConverter($stateProviderProphecy = null, $routerProphecy = null, $identifiersExtractorProphecy = null, $resourceMetadataCollectionFactoryProphecy = null, $uriVariablesConverter = null, $decorated = null) { if (!$stateProviderProphecy) { $stateProviderProphecy = $this->prophesize(ProviderInterface::class); @@ -299,6 +301,6 @@ private function getIriConverter($stateProviderProphecy = null, $routerProphecy $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); } - return new IriConverter($stateProviderProphecy->reveal(), $routerProphecy->reveal(), $identifiersExtractorProphecy->reveal(), $this->getResourceClassResolver(), $resourceMetadataCollectionFactoryProphecy->reveal(), $uriVariablesConverter); + return new IriConverter($stateProviderProphecy->reveal(), $routerProphecy->reveal(), $identifiersExtractorProphecy->reveal(), $this->getResourceClassResolver(), $resourceMetadataCollectionFactoryProphecy->reveal(), $uriVariablesConverter, $decorated); } } From 39ad8942a21e2f9bbb6d66d39541c37cc4136278 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 31 Aug 2022 17:06:45 +0200 Subject: [PATCH 09/12] refactor(itemUriTemplate): index by uri template --- src/Hydra/Serializer/CollectionNormalizer.php | 2 +- .../Resource/ResourceMetadataCollection.php | 28 +++---------------- .../AbstractCollectionNormalizer.php | 2 +- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index d0302617869..91a2903edca 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -101,7 +101,7 @@ public function normalize($object, $format = null, array $context = []): array $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; if ($this->resourceMetadataCollectionFactory && ($operation = $context['operation'] ?? null) instanceof CollectionOperationInterface && ($itemUriTemplate = $operation->getItemUriTemplate())) { - $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->matchOperation($itemUriTemplate); + $context['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation($operation->getItemUriTemplate()); } else { unset($context['operation']); } diff --git a/src/Metadata/Resource/ResourceMetadataCollection.php b/src/Metadata/Resource/ResourceMetadataCollection.php index 86f4a5a7b12..479b18bb979 100644 --- a/src/Metadata/Resource/ResourceMetadataCollection.php +++ b/src/Metadata/Resource/ResourceMetadataCollection.php @@ -61,6 +61,10 @@ public function getOperation(?string $operationName = null, bool $forceCollectio if ($name === $operationName) { return $this->operationCache[$operationName] = $operation; } + + if ($operation->getUriTemplate() === $operationName) { + return $this->operationCache[$operationName] = $operation; + } } foreach ($metadata->getGraphQlOperations() ?? [] as $name => $operation) { @@ -80,30 +84,6 @@ public function getOperation(?string $operationName = null, bool $forceCollectio $this->handleNotFound($operationName, $metadata); } - /** - * Find a Get operation which matches the uriTemplate. - */ - public function matchOperation(string $uriTemplate): Operation - { - $it = $this->getIterator(); - $metadata = null; - - while ($it->valid()) { - /** @var ApiResource $metadata */ - $metadata = $it->current(); - - foreach ($metadata->getOperations() ?? [] as $operation) { - if ($uriTemplate === $operation->getUriTemplate() && HttpOperation::METHOD_GET === $operation->getMethod() && !$operation instanceof CollectionOperationInterface) { - return $operation; - } - } - - $it->next(); - } - - $this->handleNotFound($uriTemplate, $metadata); - } - /** * @throws OperationNotFoundException */ diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index e96c1f2945e..6b66d41a96b 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -95,7 +95,7 @@ public function normalize($object, $format = null, array $context = []) /** @var ResourceMetadata|ResourceMetadataCollection */ $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); if ($metadata instanceof ResourceMetadataCollection && ($operation = $context['operation'] ?? null) instanceof CollectionOperationInterface && ($itemUriTemplate = $operation->getItemUriTemplate())) { - $context['operation'] = $metadata->matchOperation($itemUriTemplate); + $context['operation'] = $metadata->getOperation($itemUriTemplate); } else { unset($context['operation']); } From 2eb2831ae43b856bf2a377279eb05ef82bdb96d3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 1 Sep 2022 10:42:38 +0200 Subject: [PATCH 10/12] review --- .../NotExposedOperationResourceMetadataCollectionFactory.php | 2 +- src/Symfony/Bundle/Resources/config/api.xml | 2 +- src/Symfony/Routing/ApiLoader.php | 2 +- src/Symfony/Routing/SkolemIriConverter.php | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php index 1cb446df830..621c82afae9 100644 --- a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -79,7 +79,7 @@ public function create(string $resourceClass): ResourceMetadataCollection $operation = $operation->withUriTemplate(SkolemIriConverter::$skolemUriTemplate); } - $operations->add(sprintf('_api_%s_get', $operation->getShortName()), $operation)->sort(); // @phpstan-ignore-line $operations exists + $operations->add(sprintf('_api_%s_get', $operation->getShortName()), $operation)->sort(); // @phpstan-ignore-line $operation exists return $resourceMetadataCollection; } diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 3a8e1e5020f..a3d499e572f 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -142,7 +142,7 @@ - + diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index 51e7a428fe5..8d4dd76e488 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -111,7 +111,7 @@ public function load($data, $type = null): RouteCollection continue; } - if ($operation->getUriTemplate() === SkolemIriConverter::$skolemUriTemplate) { + if (SkolemIriConverter::$skolemUriTemplate === $operation->getUriTemplate()) { continue; } diff --git a/src/Symfony/Routing/SkolemIriConverter.php b/src/Symfony/Routing/SkolemIriConverter.php index e3b15179d35..171127f9938 100644 --- a/src/Symfony/Routing/SkolemIriConverter.php +++ b/src/Symfony/Routing/SkolemIriConverter.php @@ -54,12 +54,13 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation */ public function getIriFromResource($item, int $referenceType = UrlGeneratorInterface::ABS_PATH, Operation $operation = null, array $context = []): ?string { + $referenceType = $operation ? ($operation->getUrlGenerationStrategy() ?? $referenceType) : $referenceType; if (($isObject = \is_object($item)) && $this->objectHashMap->contains($item)) { - return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$item]], $operation?->getUrlGenerationStrategy() ?? $referenceType); + return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$item]], $referenceType); } if (\is_string($item) && isset($this->classHashMap[$item])) { - return $this->router->generate('api_genid', ['id' => $this->classHashMap[$item]], $operation?->getUrlGenerationStrategy() ?? $referenceType); + return $this->router->generate('api_genid', ['id' => $this->classHashMap[$item]], $referenceType); } $id = bin2hex(random_bytes(10)); From c7799f6b861af25e2ef6d784d6df47cc277db4a4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 1 Sep 2022 10:43:43 +0200 Subject: [PATCH 11/12] chore(phpunit): missing phpspec/prophecy --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 5826a3a7400..a5ddcde857a 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "justinrainbow/json-schema": "^5.2.1", "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.1", "phpdocumentor/type-resolver": "^0.3 || ^0.4 || ^1.4", + "phpspec/prophecy": "^1.10", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.1", "phpstan/phpstan-doctrine": "^1.0", From f0aaa36dea02d094e890e73d1854953c1571ce38 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 1 Sep 2022 11:01:24 +0200 Subject: [PATCH 12/12] fix tests --- src/Symfony/Routing/IriConverter.php | 2 +- src/Symfony/Routing/SkolemIriConverter.php | 2 +- ...erationResourceMetadataCollectionFactoryTest.php | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 26444801e8b..6c9963352f9 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -163,7 +163,7 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter if (!$operation->getName() || ($operation instanceof HttpOperation && SkolemIriConverter::$skolemUriTemplate === $operation->getUriTemplate())) { if (!$this->decorated) { - throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', $resourceClass), $e->getCode(), $e); + throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', $resourceClass)); } // Use a skolem iri, the route is defined in genid.xml random bytes as a hash map + virer les operation name == uri template sauf en interne dans symfony diff --git a/src/Symfony/Routing/SkolemIriConverter.php b/src/Symfony/Routing/SkolemIriConverter.php index 171127f9938..1a6760991d1 100644 --- a/src/Symfony/Routing/SkolemIriConverter.php +++ b/src/Symfony/Routing/SkolemIriConverter.php @@ -71,6 +71,6 @@ public function getIriFromResource($item, int $referenceType = UrlGeneratorInter $this->classHashMap[$item] = $id; } - return $this->router->generate('api_genid', ['id' => $id], $operation?->getUrlGenerationStrategy() ?? $referenceType); + return $this->router->generate('api_genid', ['id' => $id], $referenceType); } } diff --git a/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php index 149f9e500f2..a2c59090926 100644 --- a/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactoryTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; @@ -37,7 +38,7 @@ class NotExposedOperationResourceMetadataCollectionFactoryTest extends TestCase public function testItIgnoresClassesWithoutResources() { $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); - $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->shouldNotBeCalled(); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->shouldNotBeCalled(); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( @@ -54,7 +55,7 @@ public function testItIgnoresClassesWithoutResources() public function testItIgnoresResourcesWithAnItemOperation() { $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); - $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->shouldNotBeCalled(); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->shouldNotBeCalled(); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( @@ -101,7 +102,7 @@ class: AttributeResource::class public function testItAddsANotExposedOperationWithoutRouteNameOnTheLastResource() { $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); - $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->willReturn([new Link()])->shouldBeCalled(); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->willReturn([new Link()])->shouldBeCalled(); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( @@ -133,7 +134,7 @@ class: AttributeResource::class shortName: 'AttributeResource', operations: [ '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), - '_api_AttributeResource_get' => new NotExposed(routeName: null, controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), + '_api_AttributeResource_get' => new NotExposed(controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), ], class: AttributeResource::class ), @@ -145,7 +146,7 @@ class: AttributeResource::class public function testItAddsANotExposedOperationWithRouteNameOnTheLastResource() { $linkFactoryProphecy = $this->prophesize(LinkFactoryInterface::class); - $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(ApiResource::class))->willReturn([])->shouldBeCalled(); + $linkFactoryProphecy->createLinksFromIdentifiers(Argument::type(HttpOperation::class))->willReturn([])->shouldBeCalled(); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(AttributeResource::class)->willReturn( @@ -177,7 +178,7 @@ class: AttributeResource::class shortName: 'AttributeResource', operations: [ '_api_AttributeResource_get_collection' => new GetCollection(controller: 'api_platform.action.placeholder', shortName: 'AttributeResource', class: AttributeResource::class), - '_api_AttributeResource_get' => new NotExposed(routeName: 'api_genid', controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), + '_api_AttributeResource_get' => new NotExposed(uriTemplate: '/.well-known/genid/{id}', controller: 'api_platform.action.not_exposed', shortName: 'AttributeResource', class: AttributeResource::class, output: false, read: false), ], class: AttributeResource::class ),