From b8ec98d9f86d90558472ccf25e64f59504debc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20H=C3=A9bert?= Date: Tue, 18 Jul 2023 00:53:57 +0200 Subject: [PATCH 1/2] feat(serializer): add ApiProperty::uriTemplate option This feature gives control over the operation used for *toOne and *toMany relations IRI generation. When defined, API Platform will use the operation declared on the related resource that matches the uriTemplate string. In addition, this will override the value returned to be the IRI string only, not an object in JSONLD formats. For HAL and JSON:API format, the IRI will be used in links properties, and in the objects embedded or in relationship properties. --- ...rn-the-iri-of-your-resources-relations.php | 208 ++++++++++++++++++ features/hal/collection_uri_template.feature | 64 ++++++ features/hal/hal.feature | 3 - .../jsonapi/collection_uri_template.feature | 56 +++++ features/jsonld/iri_only.feature | 37 ++++ .../Orm/Extension/EagerLoadingExtension.php | 3 +- src/Hal/Serializer/ItemNormalizer.php | 45 +++- src/JsonApi/Serializer/ItemNormalizer.php | 26 ++- .../Factory/SchemaPropertyMetadataFactory.php | 7 +- src/Metadata/ApiProperty.php | 22 +- .../Extractor/XmlPropertyExtractor.php | 1 + .../Extractor/YamlPropertyExtractor.php | 1 + src/Metadata/Extractor/schema/properties.xsd | 1 + .../Extractor/Adapter/XmlPropertyAdapter.php | 1 + .../Tests/Extractor/Adapter/properties.xml | 2 +- .../Tests/Extractor/Adapter/properties.yaml | 1 + .../PropertyMetadataCompatibilityTest.php | 1 + src/Serializer/AbstractItemNormalizer.php | 42 +++- .../Tests/AbstractItemNormalizerTest.php | 100 +++++++++ .../ApiResource/PropertyCollectionIriOnly.php | 112 ++++++++++ .../PropertyCollectionIriOnlyRelation.php | 65 ++++++ tests/Behat/DoctrineContext.php | 27 +++ .../Extension/EagerLoadingExtensionTest.php | 37 ++++ tests/Fixtures/JsonHal/jsonhal.json | 185 ++++++++++------ .../Document/PropertyCollectionIriOnly.php | 119 ++++++++++ .../PropertyCollectionIriOnlyRelation.php | 59 +++++ .../PropertyUriTemplateOneToOneRelation.php | 61 +++++ .../Entity/PropertyCollectionIriOnly.php | 121 ++++++++++ .../PropertyCollectionIriOnlyRelation.php | 66 ++++++ .../PropertyUriTemplateOneToOneRelation.php | 65 ++++++ .../JsonApi/Serializer/ItemNormalizerTest.php | 5 + 31 files changed, 1451 insertions(+), 92 deletions(-) create mode 100644 docs/guides/return-the-iri-of-your-resources-relations.php create mode 100644 features/hal/collection_uri_template.feature create mode 100644 features/jsonapi/collection_uri_template.feature create mode 100644 src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnly.php create mode 100644 src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnlyRelation.php create mode 100644 tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php create mode 100644 tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php create mode 100644 tests/Fixtures/TestBundle/Document/PropertyUriTemplateOneToOneRelation.php create mode 100644 tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnly.php create mode 100644 tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnlyRelation.php create mode 100644 tests/Fixtures/TestBundle/Entity/PropertyUriTemplateOneToOneRelation.php diff --git a/docs/guides/return-the-iri-of-your-resources-relations.php b/docs/guides/return-the-iri-of-your-resources-relations.php new file mode 100644 index 00000000000..92602910eb3 --- /dev/null +++ b/docs/guides/return-the-iri-of-your-resources-relations.php @@ -0,0 +1,208 @@ + ['read']], provider: Brand::class), + ], + )] + class Brand implements ProviderInterface + { + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id = 1, + + #[Groups('read')] + public readonly string $name = 'Anon', + + // Setting uriTemplate on a relation with a resource collection will try to find the related operation. + // It is based on the uriTemplate set on the operation defined on the Car resource (see below). + /** + * @var array $cars + */ + #[ApiProperty(uriTemplate: '/brands/{brandId}/cars')] + #[Groups('read')] + private array $cars = [], + + // Setting uriTemplate on a relation with a resource item will try to find the related operation. + // It is based on the uriTemplate set on the operation defined on the Address resource (see below). + #[ApiProperty(uriTemplate: '/brands/{brandId}/addresses/{id}')] + #[Groups('read')] + private ?Address $headQuarters = null + ) + { + } + + /** + * @return array + */ + public function getCars(): array + { + return $this->cars; + } + + public function addCar(Car $car): self + { + $car->setBrand($this); + $this->cars[] = $car; + + return $this; + } + + public function getHeadQuarters(): ?Address + { + return $this->headQuarters; + } + + public function setHeadQuarters(?Address $headQuarters): self + { + $headQuarters?->setBrand($this); + $this->headQuarters = $headQuarters; + + return $this; + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return (new self(1, 'Ford')) + ->setHeadQuarters(new Address(1, 'One American Road near Michigan Avenue, Dearborn, Michigan')) + ->addCar(new Car(1, 'Torpedo Roadster')); + } + } + + #[ApiResource( + operations: [ + new Get, + // Without the use of uriTemplate on the property this would be used coming from the Brand resource, but not anymore. + new GetCollection(uriTemplate: '/cars'), + // This operation will be used to create the IRI instead since the uriTemplate matches. + new GetCollection( + uriTemplate: '/brands/{brandId}/cars', + uriVariables: [ + 'brandId' => new Link(toProperty: 'brand', fromClass: Brand::class), + ] + ), + ], + )] + class Car + { + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id = 1, + public readonly string $name = 'Anon', + private ?Brand $brand = null + ) + { + } + + public function getBrand(): Brand + { + return $this->brand; + } + + public function setBrand(Brand $brand): void + { + $this->brand = $brand; + } + } + + #[ApiResource( + operations: [ + // Without the use of uriTemplate on the property this would be used coming from the Brand resource, but not anymore. + new Get(uriTemplate: '/addresses/{id}'), + // This operation will be used to create the IRI instead since the uriTemplate matches. + new Get( + uriTemplate: '/brands/{brandId}/addresses/{id}', + uriVariables: [ + 'brandId' => new Link(toProperty: 'brand', fromClass: Brand::class), + 'id' => new Link(fromClass: Address::class), + ] + ) + ], + )] + class Address + { + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id = 1, + #[Groups('read')] + public readonly string $name = 'Anon', + private ?Brand $brand = null + ) + { + } + + public function getBrand(): Brand + { + return $this->brand; + } + + public function setBrand(Brand $brand): void + { + $this->brand = $brand; + } + } +} + +// If API Platform does not find any `GetCollection` operation on the target resource, it will result in a `NotFoundException`. +// +// The **OpenAPI** documentation will set the properties as `read-only` of type `string` in the format `iri-reference` for `JSON-LD`, `JSON:API` and `HAL` formats. +// +// The **Hydra** documentation will set the properties as `hydra:Link` with the right domain, with `hydra:readable` to `true` but `hydra:writable` to `false`. +// +// When using JSON:API or HAL formats, the IRI will be used and set links, embedded and relationship. +// +// *Additional Note:* If you are using the default doctrine provider, this will prevent unnecessary sql join and related processing. + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create('/brands/1.jsonld', 'GET'); + } +} + + +namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\ApiResource\Brand; + + final class BrandTest extends ApiTestCase + { + use TestGuideTrait; + + public function testResourceExposeIRI(): void + { + static::createClient()->request('GET', '/brands/1.jsonld'); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesResourceCollectionJsonSchema(Brand::class, '_api_/brand{._format}_get_item'); + $this->assertJsonContains([ + "@context" => "/contexts/Brand", + "@id" => "/brands/1", + "@type" => "Brand", + "cars" => "/brands/1/cars", + "headQuarters" => "/brands/1/addresses/1" + ]); + } + } +} diff --git a/features/hal/collection_uri_template.feature b/features/hal/collection_uri_template.feature new file mode 100644 index 00000000000..0af300f6c66 --- /dev/null +++ b/features/hal/collection_uri_template.feature @@ -0,0 +1,64 @@ +@php8 +@v3 +Feature: Exposing a property being a collection of resources + can return an IRI instead of an array + when the uriTemplate is set on the ApiProperty attribute + + @createSchema + Scenario: Retrieve Resource with uriTemplate collection Property + Given there are propertyCollectionIriOnly with relations + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/property_collection_iri_onlies/1" + 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 equal to: + """ + { + "_links": { + "self": { + "href": "/property_collection_iri_onlies/1" + }, + "propertyCollectionIriOnlyRelation": { + "href": "/property-collection-relations" + }, + "iterableIri": { + "href": "/parent/1/another-collection-operations" + }, + "toOneRelation": { + "href": "/parent/1/property-uri-template/one-to-ones/1" + } + }, + "_embedded": { + "propertyCollectionIriOnlyRelation": [ + { + "_links": { + "self": { + "href": "/property_collection_iri_only_relations/1" + } + }, + "name": "asb" + } + ], + "iterableIri": [ + { + "_links": { + "self": { + "href": "/property_collection_iri_only_relations/9999" + } + }, + "name": "Michel" + } + ], + "toOneRelation": { + "_links": { + "self": { + "href": "/parent/1/property-uri-template/one-to-ones/1" + } + }, + "name": "xarguš" + } + } + } + """ diff --git a/features/hal/hal.feature b/features/hal/hal.feature index d573b54c6d2..be469651f58 100644 --- a/features/hal/hal.feature +++ b/features/hal/hal.feature @@ -170,7 +170,6 @@ Feature: HAL support } """ - Scenario: Embed a relation in a parent object When I add "Content-Type" header equal to "application/json" And I send a "POST" request to "/relation_embedders" with body: @@ -180,8 +179,6 @@ Feature: HAL support } """ Then the response status code should be 201 - - Scenario: Get the object with the embedded relation When I add "Accept" header equal to "application/hal+json" And I send a "GET" request to "/relation_embedders/1" Then the response status code should be 200 diff --git a/features/jsonapi/collection_uri_template.feature b/features/jsonapi/collection_uri_template.feature new file mode 100644 index 00000000000..188895d0e7a --- /dev/null +++ b/features/jsonapi/collection_uri_template.feature @@ -0,0 +1,56 @@ +@php8 +@v3 +Feature: Exposing a property being a collection of resources + can return an IRI instead of an array + when the uriTemplate is set on the ApiProperty attribute + + Background: + Given I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/vnd.api+json" + + @createSchema + Scenario: Retrieve Resource with uriTemplate collection Property + Given there are propertyCollectionIriOnly with relations + And I send a "GET" request to "/property_collection_iri_onlies/1" + 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 equal to: + """ + { + "links": { + "propertyCollectionIriOnlyRelation": "/property-collection-relations", + "iterableIri": "/parent/1/another-collection-operations", + "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" + }, + "data": { + "id": "/property_collection_iri_onlies/1", + "type": "PropertyCollectionIriOnly", + "relationships": { + "propertyCollectionIriOnlyRelation": { + "data": [ + { + "type": "PropertyCollectionIriOnlyRelation", + "id": "/property_collection_iri_only_relations/1" + } + ] + }, + "iterableIri": { + "data": [ + { + "type": "PropertyCollectionIriOnlyRelation", + "id": "/property_collection_iri_only_relations/9999" + } + ] + }, + "toOneRelation": { + "data": { + "type": "PropertyUriTemplateOneToOneRelation", + "id": "/parent/1/property-uri-template/one-to-ones/1" + } + } + } + } + } + """ diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature index d2ac3db1323..170e7f69f86 100644 --- a/features/jsonld/iri_only.feature +++ b/features/jsonld/iri_only.feature @@ -56,3 +56,40 @@ Feature: JSON-LD using iri_only parameter "hydra:totalItems": 3 } """ + + @createSchema + Scenario: Retrieve Resource with uriTemplate collection Property + Given there are propertyCollectionIriOnly with relations + When I send a "GET" request to "/property_collection_iri_onlies" + 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 a superset of: + """ + { + "hydra:member": [ + { + "@id": "/property_collection_iri_onlies/1", + "@type": "PropertyCollectionIriOnly", + "propertyCollectionIriOnlyRelation": "/property-collection-relations", + "iterableIri": "/parent/1/another-collection-operations", + "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" + } + ] + } + """ + When I send a "GET" request to "/property_collection_iri_onlies/1" + 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 a superset of: + """ + { + "@context": "/contexts/PropertyCollectionIriOnly", + "@id": "/property_collection_iri_onlies/1", + "@type": "PropertyCollectionIriOnly", + "propertyCollectionIriOnlyRelation": "/property-collection-relations", + "iterableIri": "/parent/1/another-collection-operations", + "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" + } + """ diff --git a/src/Doctrine/Orm/Extension/EagerLoadingExtension.php b/src/Doctrine/Orm/Extension/EagerLoadingExtension.php index eb7f939f47e..4644283f1f3 100644 --- a/src/Doctrine/Orm/Extension/EagerLoadingExtension.php +++ b/src/Doctrine/Orm/Extension/EagerLoadingExtension.php @@ -155,8 +155,9 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt } $fetchEager = $propertyMetadata->getFetchEager(); + $uriTemplate = $propertyMetadata->getUriTemplate(); - if (false === $fetchEager) { + if (false === $fetchEager || null !== $uriTemplate) { continue; } diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index d54d601e91a..5e13da5026c 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -67,6 +67,7 @@ public function normalize(mixed $object, string $format = null, array $context = $context = $this->initContext($resourceClass, $context); $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); + $context['iri'] = $iri; $context['api_normalize'] = true; @@ -75,6 +76,7 @@ public function normalize(mixed $object, string $format = null, array $context = } $data = parent::normalize($object, $format, $context); + if (!\is_array($data)) { return $data; } @@ -166,7 +168,23 @@ private function getComponents(object $object, ?string $format, array $context): continue; } - $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many']; + $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many', 'iri' => null, 'operation' => null]; + + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if (($className ?? false) && $uriTemplate = $propertyMetadata->getUriTemplate()) { + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation'], $childContext['operation_name']); + + $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( + operationName: $uriTemplate, + httpOperation: true + ); + + $relation['iri'] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + $relation['operation'] = $operation; + } + if ($propertyMetadata->isReadableLink()) { $components['embedded'][] = $relation; } @@ -205,16 +223,31 @@ private function populateRelation(array $data, object $object, ?string $format, continue; } - $attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $context); - if (empty($attributeValue)) { - continue; - } - $relationName = $relation['name']; if ($this->nameConverter) { $relationName = $this->nameConverter->normalize($relationName, $class, $format, $context); } + // if we specify the uriTemplate, then the link takes the uriTemplate defined. + if ('links' === $type && $iri = $relation['iri']) { + $data[$key][$relationName]['href'] = $iri; + continue; + } + + $childContext = $this->createChildContext($context, $relationName, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['operation'], $childContext['operation_name']); + + if ($operation = $relation['operation']) { + $childContext['operation'] = $operation; + $childContext['operation_name'] = $operation->getName(); + } + + $attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $childContext); + + if (empty($attributeValue)) { + continue; + } + if ('one' === $relation['cardinality']) { if ('links' === $type) { $data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue); diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 40e274306e3..b303570b836 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -104,7 +104,7 @@ public function normalize(mixed $object, string $format = null, array $context = } // Get and populate relations - $allRelationshipsData = $this->getComponents($object, $format, $context)['relationships']; + ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($object, $format, $context); $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); @@ -126,7 +126,13 @@ public function normalize(mixed $object, string $format = null, array $context = $resourceData['relationships'] = $relationshipsData; } - $document = ['data' => $resourceData]; + $document = []; + + if ($links) { + $document['links'] = $links; + } + + $document['data'] = $resourceData; if ($includedResourcesData) { $document['included'] = $includedResourcesData; @@ -311,6 +317,22 @@ private function getComponents(object $object, ?string $format, array $context): 'cardinality' => $isOne ? 'one' : 'many', ]; + // if we specify the uriTemplate, generates its value for link definition + // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content + if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); + $childContext = $this->createChildContext($context, $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']); + + $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation( + operationName: $itemUriTemplate, + httpOperation: true + ); + + $components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } + $components['relationships'][] = $relation; $isRelationship = true; } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index cf1c294e19e..ad806636771 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -49,7 +49,7 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema = $propertyMetadata->getSchema() ?? []; - if (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) { $propertySchema['readOnly'] = true; } @@ -124,6 +124,11 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['owl:maxCardinality'] = 1; } + if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { + $keyType = null; + $isCollection = false; + } + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); if (!\in_array($propertyType, $valueSchema, true)) { $valueSchema[] = $propertyType; diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index c9fa67f6692..346fd82e2b8 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -40,6 +40,7 @@ final class ApiProperty * @param string[] $types the RDF types of this property * @param string[] $iris * @param Type[] $builtinTypes + * @param string|null $uriTemplate whether to return the subRessource collection IRI instead of an iterable of IRI */ public function __construct( private ?string $description = null, @@ -105,7 +106,7 @@ public function __construct( private ?string $security = null, private ?string $securityPostDenormalize = null, private array|string|null $types = null, - /** + /* * The related php types. */ private ?array $builtinTypes = null, @@ -113,7 +114,8 @@ public function __construct( private ?bool $initializable = null, private $iris = null, private ?bool $genId = null, - private array $extraProperties = [] + private ?string $uriTemplate = null, + private array $extraProperties = [], ) { if (\is_string($types)) { $this->types = (array) $types; @@ -464,4 +466,20 @@ public function withGenId(bool $genId): self return $metadata; } + + /** + * Whether to return the subRessource collection IRI instead of an iterable of IRI. + */ + public function getUriTemplate(): ?string + { + return $this->uriTemplate; + } + + public function withUriTemplate(?string $uriTemplate): self + { + $metadata = clone $this; + $metadata->uriTemplate = $uriTemplate; + + return $metadata; + } } diff --git a/src/Metadata/Extractor/XmlPropertyExtractor.php b/src/Metadata/Extractor/XmlPropertyExtractor.php index 062c26736d9..761a73e5424 100644 --- a/src/Metadata/Extractor/XmlPropertyExtractor.php +++ b/src/Metadata/Extractor/XmlPropertyExtractor.php @@ -72,6 +72,7 @@ protected function extractPath(string $path): void 'extraProperties' => $this->buildExtraProperties($property, 'extraProperties'), 'iris' => $this->buildArrayValue($property, 'iri'), 'genId' => $this->phpize($property, 'genId', 'bool'), + 'uriTemplate' => $this->phpize($property, 'uriTemplate', 'string'), ]; } } diff --git a/src/Metadata/Extractor/YamlPropertyExtractor.php b/src/Metadata/Extractor/YamlPropertyExtractor.php index c92a7ded526..ae70fc14409 100644 --- a/src/Metadata/Extractor/YamlPropertyExtractor.php +++ b/src/Metadata/Extractor/YamlPropertyExtractor.php @@ -93,6 +93,7 @@ private function buildProperties(array $resourcesYaml): void 'builtinTypes' => $this->buildAttribute($propertyValues, 'builtinTypes'), 'schema' => $this->buildAttribute($propertyValues, 'schema'), 'genId' => $this->phpize($propertyValues, 'genId', 'bool'), + 'uriTemplate' => $this->phpize($propertyValues, 'uriTemplate', 'string'), ]; } } diff --git a/src/Metadata/Extractor/schema/properties.xsd b/src/Metadata/Extractor/schema/properties.xsd index f25266eba22..814e3a2aba6 100644 --- a/src/Metadata/Extractor/schema/properties.xsd +++ b/src/Metadata/Extractor/schema/properties.xsd @@ -44,6 +44,7 @@ + diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php index 336d9275b31..5931d4e870f 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php @@ -43,6 +43,7 @@ final class XmlPropertyAdapter implements PropertyAdapterInterface 'initializable', 'iris', 'genId', + 'uriTemplate', ]; /** diff --git a/src/Metadata/Tests/Extractor/Adapter/properties.xml b/src/Metadata/Tests/Extractor/Adapter/properties.xml index ab87fb13133..08d05386940 100644 --- a/src/Metadata/Tests/Extractor/Adapter/properties.xml +++ b/src/Metadata/Tests/Extractor/Adapter/properties.xml @@ -1,3 +1,3 @@ -bazbaripsumsomeirischemaanotheririschemastringhttps://schema.org/Thinghttps://schema.org/totalPriceLorem ipsum dolor sit amet +bazbaripsumsomeirischemaanotheririschemastringhttps://schema.org/Thinghttps://schema.org/totalPriceLorem ipsum dolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/properties.yaml b/src/Metadata/Tests/Extractor/Adapter/properties.yaml index 7a80dceec61..7fc50a1b4f8 100644 --- a/src/Metadata/Tests/Extractor/Adapter/properties.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/properties.yaml @@ -37,3 +37,4 @@ properties: iris: - 'https://schema.org/totalPrice' genId: true + uriTemplate: /sub-resource-get-collection diff --git a/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php index 340646fd13d..d83794ddc02 100644 --- a/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php @@ -72,6 +72,7 @@ final class PropertyMetadataCompatibilityTest extends TestCase ], 'iris' => ['https://schema.org/totalPrice'], 'genId' => true, + 'uriTemplate' => '/sub-resource-get-collection', ]; /** diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 8ee7c2e5410..566bcadf27d 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -607,10 +607,8 @@ protected function getAttributeValue(object $object, string $attribute, string $ $context['api_attribute'] = $attribute; $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - if ($context['api_denormalize'] ?? false) { - return $attributeValue; + return $this->propertyAccessor->getValue($object, $attribute); } $types = $propertyMetadata->getBuiltinTypes() ?? []; @@ -622,12 +620,27 @@ protected function getAttributeValue(object $object, string $attribute, string $ && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className) ) { + $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format); + + // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content + // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content + if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) { + $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( + operationName: $itemUriTemplate, + forceCollection: true, + httpOperation: true + ); + + return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } + + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + if (!is_iterable($attributeValue)) { throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); } $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format); return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } @@ -636,12 +649,25 @@ protected function getAttributeValue(object $object, string $attribute, string $ ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className) ) { + $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format); + unset($childContext['iri'], $childContext['uri_variables']); + + if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) { + $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( + operationName: $uriTemplate, + httpOperation: true + ); + + return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); + } + + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + if (!\is_object($attributeValue) && null !== $attributeValue) { throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); } $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format); return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); } @@ -660,12 +686,16 @@ protected function getAttributeValue(object $object, string $attribute, string $ $childContext = $this->createChildContext($this->createOperationContext($context, null), $attribute, $format); $childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true; + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + return $this->serializer->normalize($attributeValue, $format, $childContext); } if ('array' === $type->getBuiltinType()) { $childContext = $this->createChildContext($this->createOperationContext($context, null), $attribute, $format); + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + return $this->serializer->normalize($attributeValue, $format, $childContext); } } @@ -677,6 +707,8 @@ protected function getAttributeValue(object $object, string $attribute, string $ unset($context['resource_class']); unset($context['force_resource_class']); + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + return $this->serializer->normalize($attributeValue, $format, $context); } diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 64188e16b1d..2676e3349a0 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -14,11 +14,18 @@ namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DtoWithNullValue; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; @@ -26,6 +33,8 @@ use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceChild; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceRelated; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\RelatedDummy; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\SecuredDummy; use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; @@ -34,6 +43,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -223,6 +233,96 @@ public function testNormalizeWithSecuredProperty(): void ])); } + public function testNormalizePropertyAsIriWithUriTemplate(): void + { + $propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation(); + $propertyCollectionIriOnlyRelation->name = 'My Relation'; + + $propertyCollectionIriOnly = new PropertyCollectionIriOnly(); + $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation); + + $collectionOperation = new GetCollection('/property-collection-relations'); + $getIterableOperation = new GetCollection('/parent/{parentId}/another-collection-operations'); + $getToOneOperation = new Get('/parent/{parentId}/another-collection-operations/{id}'); + + $resourceRelationMetadataCollection = new ResourceMetadataCollection(PropertyCollectionIriOnlyRelation::class, [ + (new ApiResource())->withOperations(new Operations([$collectionOperation, $getIterableOperation, $getToOneOperation])), + ]); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(PropertyCollectionIriOnlyRelation::class)->willReturn($resourceRelationMetadataCollection); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(PropertyCollectionIriOnly::class, ['normalization_groups' => null, 'denormalization_groups' => null, 'operation_name' => null])->willReturn( + new PropertyNameCollection(['propertyCollectionIriOnlyRelation', 'iterableIri', 'toOneRelation']) + ); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', ['normalization_groups' => null, 'denormalization_groups' => null, 'operation_name' => null])->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withBuiltinTypes([ + new Type('iterable', false, null, true, new Type('int', false, null, false), new Type('object', false, PropertyCollectionIriOnlyRelation::class, false)), + ]) + ); + + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', ['normalization_groups' => null, 'denormalization_groups' => null, 'operation_name' => null])->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withBuiltinTypes([ + new Type('iterable', false, null, true, new Type('int', false, null, false), new Type('object', false, PropertyCollectionIriOnlyRelation::class, false)), + ]) + ); + + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', ['normalization_groups' => null, 'denormalization_groups' => null, 'operation_name' => null])->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withBuiltinTypes([ + new Type('object', false, PropertyCollectionIriOnlyRelation::class, false), + ]) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_URL, null, Argument::any())->willReturn('/property-collection-relations', '/parent/42/another-collection-operations'); + $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class), Argument::any())->willReturn('/property-collection-relations', '/parent/42/another-collection-operations'); + $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_PATH, Argument::type(Get::class), Argument::any())->willReturn('/parent/42/another-collection-operations/24'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($propertyCollectionIriOnly, 'propertyCollectionIriOnlyRelation')->willReturn([$propertyCollectionIriOnlyRelation]); + $propertyAccessorProphecy->getValue($propertyCollectionIriOnly, 'iterableIri')->willReturn($propertyCollectionIriOnlyRelation); + $propertyAccessorProphecy->getValue($propertyCollectionIriOnly, 'toOneRelation')->willReturn($propertyCollectionIriOnlyRelation); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $resourceClassResolverProphecy->isResourceClass(PropertyCollectionIriOnly::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, PropertyCollectionIriOnly::class)->willReturn(PropertyCollectionIriOnly::class); + + $resourceClassResolverProphecy->isResourceClass(PropertyCollectionIriOnlyRelation::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass([$propertyCollectionIriOnlyRelation], PropertyCollectionIriOnlyRelation::class)->willReturn(PropertyCollectionIriOnlyRelation::class); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + new PropertyAccessor(), // $propertyAccessorProphecy->reveal(), + null, + null, + [], + $resourceMetadataCollectionFactoryProphecy->reveal(), + null, + ]); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/42/another-collection-operations', + 'toOneRelation' => '/parent/42/another-collection-operations/24', + ]; + + $this->assertSame($expected, $normalizer->normalize($propertyCollectionIriOnly, 'jsonld', [ + 'resources' => [], + 'root_operation' => new GetCollection('/property_collection_iri_onlies'), + ])); + } + public function testDenormalizeWithSecuredProperty(): void { $data = [ diff --git a/src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnly.php b/src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnly.php new file mode 100644 index 00000000000..3a008e9da98 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnly.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Assert that a property being a collection set with ApiProperty::UriTemplate to true returns only the IRI of the collection. + */ +#[ + Post, + Get(normalizationContext: ['groups' => ['read']]), + GetCollection(normalizationContext: ['groups' => ['read']]), +] +class PropertyCollectionIriOnly +{ + private ?int $id = null; + + #[ApiProperty(uriTemplate: '/property-collection-relations')] + #[Groups('read')] + private Collection $propertyCollectionIriOnlyRelation; + + /** + * @var array $iterableIri + */ + #[ApiProperty(uriTemplate: '/parent/{parentId}/another-collection-operations')] + #[Groups('read')] + private array $iterableIri = []; + + #[ApiProperty(uriTemplate: '/parent/{parentId}/another-collection-operations/{id}')] + #[Groups('read')] + private PropertyCollectionIriOnlyRelation $toOneRelation; + + public function __construct() + { + $this->propertyCollectionIriOnlyRelation = new ArrayCollection(); + + $this->toOneRelation = new PropertyCollectionIriOnlyRelation(); + $this->toOneRelation->name = 'Roger'; + $this->toOneRelation->setPropertyCollectionIriOnly($this); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getPropertyCollectionIriOnlyRelation(): Collection + { + return $this->propertyCollectionIriOnlyRelation; + } + + public function addPropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self + { + if (!$this->propertyCollectionIriOnlyRelation->contains($propertyCollectionIriOnlyRelation)) { + $this->propertyCollectionIriOnlyRelation->add($propertyCollectionIriOnlyRelation); + $propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly($this); + } + + return $this; + } + + public function removePropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self + { + if ($this->propertyCollectionIriOnlyRelation->removeElement($propertyCollectionIriOnlyRelation)) { + // set the owning side to null (unless already changed) + if ($propertyCollectionIriOnlyRelation->getPropertyCollectionIriOnly() === $this) { + $propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly(null); + } + } + + return $this; + } + + /** + * @return array + */ + public function getIterableIri(): array + { + $propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation(); + $propertyCollectionIriOnlyRelation->name = 'Michel'; + + $this->iterableIri = [$propertyCollectionIriOnlyRelation]; + + return $this->iterableIri; + } + + public function getToOneRelation(): PropertyCollectionIriOnlyRelation + { + return $this->toOneRelation; + } +} diff --git a/src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnlyRelation.php b/src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnlyRelation.php new file mode 100644 index 00000000000..3dcc84c2a33 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/PropertyCollectionIriOnlyRelation.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ + Post, + GetCollection(uriTemplate: '/property-collection-relations'), + GetCollection( + uriTemplate: '/parent/{parentId}/another-collection-operations', + uriVariables: [ + 'parentId' => new Link(fromProperty: 'propertyCollectionIriOnly', fromClass: PropertyCollectionIriOnly::class), + ] + ), + Get( + uriTemplate: '/parent/{parentId}/another-collection-operations/{id}', + uriVariables: [ + 'parentId' => new Link(fromProperty: 'propertyCollectionIriOnly', fromClass: PropertyCollectionIriOnly::class), + 'id' => new Link(fromProperty: 'id', toClass: PropertyCollectionIriOnlyRelation::class), + ] + ) +] +class PropertyCollectionIriOnlyRelation +{ + /** + * The entity ID. + */ + private ?int $id = null; + + #[Groups('read')] + public string $name = ''; + + private ?PropertyCollectionIriOnly $propertyCollectionIriOnly = null; + + public function getId(): ?int + { + return $this->id ?? 9999; + } + + public function getPropertyCollectionIriOnly(): ?PropertyCollectionIriOnly + { + return $this->propertyCollectionIriOnly; + } + + public function setPropertyCollectionIriOnly(?PropertyCollectionIriOnly $propertyCollectionIriOnly): void + { + $this->propertyCollectionIriOnly = $propertyCollectionIriOnly; + } +} diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 07511729f6a..89d4d9bfdd9 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -77,6 +77,9 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Product as ProductDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Program as ProgramDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnly as PropertyCollectionIriOnlyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnlyRelation as PropertyCollectionIriOnlyRelationDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyUriTemplateOneToOneRelation as PropertyUriTemplateOneToOneRelationDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Question as QuestionDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument; @@ -165,6 +168,9 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Product; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Program; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; @@ -1955,6 +1961,27 @@ public function thereAreIriOnlyDummies(int $nb): void $this->manager->flush(); } + /** + * @Given there are propertyCollectionIriOnly with relations + */ + public function thereAreResourcesWithPropertyUriTemplates(): void + { + $propertyCollectionIriOnlyRelation = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); + $propertyCollectionIriOnlyRelation->name = 'asb'; + + $propertyToOneRelation = $this->isOrm() ? new PropertyUriTemplateOneToOneRelation() : new PropertyUriTemplateOneToOneRelationDocument(); + $propertyToOneRelation->name = 'xarguš'; + + $propertyCollectionIriOnly = $this->isOrm() ? new PropertyCollectionIriOnly() : new PropertyCollectionIriOnlyDocument(); + $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation); + $propertyCollectionIriOnly->setToOneRelation($propertyToOneRelation); + + $this->manager->persist($propertyCollectionIriOnly); + $this->manager->persist($propertyCollectionIriOnlyRelation); + $this->manager->persist($propertyToOneRelation); + $this->manager->flush(); + } + /** * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy */ diff --git a/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php b/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php index 3e57c0cd111..a4e8bbbaeba 100644 --- a/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php +++ b/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php @@ -28,6 +28,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConcreteDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UnknownDummy; use Doctrine\ORM\EntityManager; @@ -884,4 +886,39 @@ public function testApplyToCollectionWithAReadableButNotFetchEagerProperty(): vo $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30); $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), Dummy::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'foo'])); } + + public function testAvoidFetchCollectionOnIriOnlyProperty(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relationPropertyMetadata = new ApiProperty(); + $relationPropertyMetadata = $relationPropertyMetadata->withFetchEager(true); + $relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(true); + $relationPropertyMetadata = $relationPropertyMetadata->withReadable(true); + $relationPropertyMetadata = $relationPropertyMetadata->withUriTemplate('/property-collection-relations'); + + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', ['serializer_groups' => ['read'], 'normalization_groups' => 'read'])->willReturn($relationPropertyMetadata)->shouldBeCalled(); + + $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->associationMappings = [ + 'propertyCollectionIriOnlyRelation' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'joinColumns' => [['nullable' => true]], 'targetEntity' => PropertyCollectionIriOnlyRelation::class], + ]; + + $emProphecy = $this->prophesize(EntityManager::class); + $emProphecy->getClassMetadata(PropertyCollectionIriOnly::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + $emProphecy->getClassMetadata(PropertyCollectionIriOnlyRelation::class)->shouldNotBecalled(); + + $queryBuilderProphecy->getRootAliases()->willReturn(['o']); + $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); + + $queryBuilderProphecy->leftJoin('o.propertyCollectionIriOnlyRelation', 'propertyCollectionIriOnlyRelation_a1')->shouldNotBeCalled(); + $queryBuilderProphecy->addSelect('propertyCollectionIriOnlyRelation_a1')->shouldNotBeCalled(); + + $queryBuilder = $queryBuilderProphecy->reveal(); + $eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), 30); + $eagerExtensionTest->applyToCollection($queryBuilder, new QueryNameGenerator(), PropertyCollectionIriOnly::class, new GetCollection(normalizationContext: [AbstractNormalizer::GROUPS => 'read'])); + } } diff --git a/tests/Fixtures/JsonHal/jsonhal.json b/tests/Fixtures/JsonHal/jsonhal.json index ff245070080..ad5f8fd19f4 100644 --- a/tests/Fixtures/JsonHal/jsonhal.json +++ b/tests/Fixtures/JsonHal/jsonhal.json @@ -1,91 +1,134 @@ { - "type": "object", - "properties": { - "_links": { - "$ref": "#/definitions/links" - }, - "_embedded": { - "$ref": "#/definitions/embedded" - } - }, + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "HAL Schema", + "description": "JSON Hypertext Application Language Version 8 Internet-Draft, according to https://tools.ietf.org/html/draft-kelly-json-hal-08 borrowed from https://github.com/scottsmith130/hal-json-schema/blob/master/hal.json", "definitions": { - "links": { - "title": "HAL Links", - "description": "Object of links with the rels as the keys", - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#/definitions/linkObject" - }, - { - "$ref": "#/definitions/linkArray" - } - ] - } - }, - "linkArray": { - "title": "HAL Link Array", - "description": "An array of linkObjects of the same link relation", - "type": "array", - "items": { - "$ref": "#/definitions/linkObject" - } - }, - "linkObject": { - "title": "HAL Link Object", - "description": "An object with link information", + "link": { + "title": "Link Object", + "description": "A Link Object represents a hyperlink from the containing resource to a URI.", "type": "object", + "required": [ + "href" + ], "properties": { - "name": { - "$ref": "base.json#/definitions/name" - }, "href": { - "anyOf": [ - { - "$ref": "link.json#/definitions/href" - }, - { - "$ref": "link.json#/definitions/hrefTemplated" - } - ] + "description": "Its value is either a URI [RFC3986] or a URI Template [RFC6570]. If the value is a URI Template then the Link Object SHOULD have a \"templated\" attribute whose value is true.", + "type": "string" }, "templated": { - "$ref": "link.json#/definitions/isTemplated" + "description": "Its value is boolean and SHOULD be true when the Link Object's \"href\" property is a URI Template. Its value SHOULD be considered false if it is undefined or any other value than true.", + "type": "boolean" }, "type": { - "$ref": "base.json#/definitions/mediaType" + "description": "Its value is a string used as a hint to indicate the media type expected when dereferencing the target resource.", + "type": "string" }, "deprecation": { - "$ref": "link.json#/definitions/isDeprecated" + "description": "Its presence indicates that the link is to be deprecated (i.e. removed) at a future date. Its value is a URL that SHOULD provide further information about the deprecation. A client SHOULD provide some notification (for example, by logging a warning message) whenever it traverses over a link that has this property. The notification SHOULD include the deprecation property's value so that a client maintainer can easily find information about the deprecation.", + "type": "string" + }, + "name": { + "description": "Its value MAY be used as a secondary key for selecting Link Objects which share the same relation type.", + "type": "string" + }, + "profile": { + "description": "Its value is a string which is a URI that hints about the profile [RFC6906] of the target resource.", + "type": "string" + }, + "title": { + "description": "Its value is a string and is intended for labelling the link with a human-readable identifier (as defined by [RFC5988]).", + "type": "string" + }, + "hreflang": { + "description": "Its value is a string and is intended for indicating the language of the target resource (as defined by [RFC5988]).", + "type": "string" + } + } + }, + "curiesLink": { + "description": "Custom link relation types (Extension Relation Types in [RFC5988]) SHOULD be URIs that when dereferenced in a web browser provide relevant documentation, in the form of an HTML page, about the meaning and/or behaviour of the target Resource. This will improve the discoverability of the API. The CURIE Syntax [W3C.NOTE-curie-20101216] MAY be used for brevity for these URIs. CURIEs are established within a HAL document via a set of Link Objects with the relation type \"curies\" on the root Resource Object. These links contain a URI Template with the token 'rel', and are named via the \"name\" property.", + "allOf": [ + { + "required": [ + "href", + "templated", + "name" + ], + "properties": { + "templated": { + "enum": [ + true + ] + } + } + }, + { + "$ref": "#/definitions/link" } - }, - "required": [ - "href" ] }, - "embedded": { - "title": "HAL Embedded Resource", - "description": "An embedded HAL resource", + "resource": { + "title": "Resource Object", + "description": "A Resource Object represents a resource. It has two reserved properties: (1) \"_links\" which contain links to other resources, and (2) \"_embedded\" which contain embedded resources. All other properties MUST be valid JSON, and represent the current state of the resource.", "type": "object", - "additionalProperties": { - "oneOf": [ - { - "$ref": "#" + "properties": { + "_links": { + "description": "It is an object whose property names are link relation types (as defined by [RFC5988]) and values are either a Link Object or an array of Link Objects. The subject resource of these links is the Resource Object of which the containing \"_links\" object is a property.", + "type": "object", + "properties": { + "curies": { + "anyOf": [ + { + "$ref": "#/definitions/curiesLink" + }, + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/curiesLink" + } + ] + } + ] + } }, - { - "$ref": "#/definitions/embeddedArray" + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/link" + }, + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/link" + } + ] + } + ] } - ] - } - }, - "embeddedArray": { - "title": "HAL Embedded Array", - "description": "An array of embedded resources with the same link relation", - "type": "array", - "items": { - "$ref": "#" + }, + "_embedded": { + "description": "It is an object whose property names are link relation types (as defined by [RFC5988]) and values are either a Resource Object or an array of Resource Objects. Embedded Resources MAY be a full, partial, or inconsistent version of the representation served from the target URI.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/resource" + }, + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/resource" + } + ] + } + ] + } + } } } - } + }, + "$ref": "#/definitions/resource" } diff --git a/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php new file mode 100644 index 00000000000..c9dca0cdcc5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php @@ -0,0 +1,119 @@ + + * + * For 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\Document; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Assert that a property being a collection set with ApiProperty::utiTemplate to true returns only the IRI of the collection. + */ +#[ + Post, + Get(normalizationContext: ['groups' => ['read']]), + GetCollection(normalizationContext: ['groups' => ['read']]), +] +#[ODM\Document] +class PropertyCollectionIriOnly +{ + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + private ?int $id = null; + + #[ODM\ReferenceMany(targetDocument: PropertyCollectionIriOnlyRelation::class)] + #[ApiProperty(uriTemplate: '/property-collection-relations')] + #[Groups('read')] + private Collection $propertyCollectionIriOnlyRelation; + + /** + * @var array $iterableIri + */ + #[ApiProperty(uriTemplate: '/parent/{parentId}/another-collection-operations')] + #[Groups('read')] + private array $iterableIri = []; + + #[ApiProperty(uriTemplate: '/parent/{parentId}/property-uri-template/one-to-ones/{id}')] + #[ODM\ReferenceOne(targetDocument: PropertyUriTemplateOneToOneRelation::class)] + #[Groups('read')] + private ?PropertyUriTemplateOneToOneRelation $toOneRelation = null; + + public function __construct() + { + $this->propertyCollectionIriOnlyRelation = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getPropertyCollectionIriOnlyRelation(): Collection + { + return $this->propertyCollectionIriOnlyRelation; + } + + public function addPropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self + { + if (!$this->propertyCollectionIriOnlyRelation->contains($propertyCollectionIriOnlyRelation)) { + $this->propertyCollectionIriOnlyRelation->add($propertyCollectionIriOnlyRelation); + $propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly($this); + } + + return $this; + } + + public function removePropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self + { + if ($this->propertyCollectionIriOnlyRelation->removeElement($propertyCollectionIriOnlyRelation)) { + // set the owning side to null (unless already changed) + if ($propertyCollectionIriOnlyRelation->getPropertyCollectionIriOnly() === $this) { + $propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly(null); + } + } + + return $this; + } + + /** + * @return array + */ + public function getIterableIri(): array + { + $propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation(); + $propertyCollectionIriOnlyRelation->name = 'Michel'; + + $this->iterableIri = [$propertyCollectionIriOnlyRelation]; + + return $this->iterableIri; + } + + public function setToOneRelation(PropertyUriTemplateOneToOneRelation $toOneRelation): void + { + $toOneRelation->setPropertyToOneIriOnly($this); + $this->toOneRelation = $toOneRelation; + } + + public function getToOneRelation(): ?PropertyUriTemplateOneToOneRelation + { + return $this->toOneRelation; + } +} diff --git a/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php new file mode 100644 index 00000000000..a7fcbd46d8d --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ + Post, + GetCollection(uriTemplate: '/property-collection-relations'), + GetCollection( + uriTemplate: '/parent/{parentId}/another-collection-operations', + uriVariables: [ + 'parentId' => new Link(toProperty: 'propertyCollectionIriOnly', fromClass: PropertyCollectionIriOnly::class), + ] + ) +] +#[ODM\Document] +class PropertyCollectionIriOnlyRelation +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + #[Groups('read')] + public string $name = ''; + + #[ODM\ReferenceOne(targetDocument: PropertyCollectionIriOnly::class)] + private ?PropertyCollectionIriOnly $propertyCollectionIriOnly = null; + + public function getId(): ?int + { + return $this->id ?? 9999; + } + + public function getPropertyCollectionIriOnly(): ?PropertyCollectionIriOnly + { + return $this->propertyCollectionIriOnly; + } + + public function setPropertyCollectionIriOnly(?PropertyCollectionIriOnly $propertyCollectionIriOnly): void + { + $this->propertyCollectionIriOnly = $propertyCollectionIriOnly; + } +} diff --git a/tests/Fixtures/TestBundle/Document/PropertyUriTemplateOneToOneRelation.php b/tests/Fixtures/TestBundle/Document/PropertyUriTemplateOneToOneRelation.php new file mode 100644 index 00000000000..a521afb13e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PropertyUriTemplateOneToOneRelation.php @@ -0,0 +1,61 @@ + + * + * For 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\Document; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ + Post, + GetCollection(uriTemplate: '/property-uri-template/one-to-ones'), + Get( + uriTemplate: '/parent/{parentId}/property-uri-template/one-to-ones/{id}', + uriVariables: [ + 'parentId' => new Link(toProperty: 'propertyToOneIriOnly', fromClass: PropertyCollectionIriOnly::class), + 'id' => new Link(fromClass: PropertyUriTemplateOneToOneRelation::class), + ] + ) +] +#[ODM\Document] +class PropertyUriTemplateOneToOneRelation +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + #[Groups('read')] + public string $name = ''; + + #[ODM\ReferenceOne(targetDocument: PropertyCollectionIriOnly::class)] + private ?PropertyCollectionIriOnly $propertyToOneIriOnly = null; + + public function getId(): ?int + { + return $this->id ?? 42; + } + + public function getPropertyToOneIriOnly(): ?PropertyCollectionIriOnly + { + return $this->propertyToOneIriOnly; + } + + public function setPropertyToOneIriOnly(?PropertyCollectionIriOnly $propertyToOneIriOnly): void + { + $this->propertyToOneIriOnly = $propertyToOneIriOnly; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnly.php b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnly.php new file mode 100644 index 00000000000..b5cddfe49d0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnly.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Assert that a property being a collection set with ApiProperty::UriTemplate to true returns only the IRI of the collection. + */ +#[ + Post, + Get(normalizationContext: ['groups' => ['read']]), + GetCollection(normalizationContext: ['groups' => ['read']]), +] +#[ORM\Entity] +class PropertyCollectionIriOnly +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\OneToMany(mappedBy: 'propertyCollectionIriOnly', targetEntity: PropertyCollectionIriOnlyRelation::class)] + #[ApiProperty(uriTemplate: '/property-collection-relations')] + #[Groups('read')] + private Collection $propertyCollectionIriOnlyRelation; + + /** + * @var array $iterableIri + */ + #[ApiProperty(uriTemplate: '/parent/{parentId}/another-collection-operations')] + #[Groups('read')] + private array $iterableIri = []; + + #[ApiProperty(uriTemplate: '/parent/{parentId}/property-uri-template/one-to-ones/{id}')] + #[ORM\OneToOne(mappedBy: 'propertyToOneIriOnly')] + #[Groups('read')] + private ?PropertyUriTemplateOneToOneRelation $toOneRelation = null; + + public function __construct() + { + $this->propertyCollectionIriOnlyRelation = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getPropertyCollectionIriOnlyRelation(): Collection + { + return $this->propertyCollectionIriOnlyRelation; + } + + public function addPropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self + { + if (!$this->propertyCollectionIriOnlyRelation->contains($propertyCollectionIriOnlyRelation)) { + $this->propertyCollectionIriOnlyRelation->add($propertyCollectionIriOnlyRelation); + $propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly($this); + } + + return $this; + } + + public function removePropertyCollectionIriOnlyRelation(PropertyCollectionIriOnlyRelation $propertyCollectionIriOnlyRelation): self + { + if ($this->propertyCollectionIriOnlyRelation->removeElement($propertyCollectionIriOnlyRelation)) { + // set the owning side to null (unless already changed) + if ($propertyCollectionIriOnlyRelation->getPropertyCollectionIriOnly() === $this) { + $propertyCollectionIriOnlyRelation->setPropertyCollectionIriOnly(null); + } + } + + return $this; + } + + /** + * @return array + */ + public function getIterableIri(): array + { + $propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation(); + $propertyCollectionIriOnlyRelation->name = 'Michel'; + + $this->iterableIri = [$propertyCollectionIriOnlyRelation]; + + return $this->iterableIri; + } + + public function setToOneRelation(PropertyUriTemplateOneToOneRelation $toOneRelation): void + { + $toOneRelation->setPropertyToOneIriOnly($this); + $this->toOneRelation = $toOneRelation; + } + + public function getToOneRelation(): ?PropertyUriTemplateOneToOneRelation + { + return $this->toOneRelation; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnlyRelation.php b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnlyRelation.php new file mode 100644 index 00000000000..ebc12163c3b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnlyRelation.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints\NotBlank; + +#[ + Post, + GetCollection(uriTemplate: '/property-collection-relations'), + GetCollection( + uriTemplate: '/parent/{parentId}/another-collection-operations', + uriVariables: [ + 'parentId' => new Link(toProperty: 'propertyCollectionIriOnly', fromClass: PropertyCollectionIriOnly::class), + ] + ) +] +#[ORM\Entity] +class PropertyCollectionIriOnlyRelation +{ + /** + * The entity ID. + */ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column] + #[NotBlank] + #[Groups('read')] + public string $name = ''; + + #[ORM\ManyToOne(inversedBy: 'propertyCollectionIriOnlyRelation')] + private ?PropertyCollectionIriOnly $propertyCollectionIriOnly = null; + + public function getId(): ?int + { + return $this->id ?? 9999; + } + + public function getPropertyCollectionIriOnly(): ?PropertyCollectionIriOnly + { + return $this->propertyCollectionIriOnly; + } + + public function setPropertyCollectionIriOnly(?PropertyCollectionIriOnly $propertyCollectionIriOnly): void + { + $this->propertyCollectionIriOnly = $propertyCollectionIriOnly; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PropertyUriTemplateOneToOneRelation.php b/tests/Fixtures/TestBundle/Entity/PropertyUriTemplateOneToOneRelation.php new file mode 100644 index 00000000000..3debae85f38 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PropertyUriTemplateOneToOneRelation.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints\NotBlank; + +#[ + Post, + GetCollection(uriTemplate: '/property-uri-template/one-to-ones'), + Get( + uriTemplate: '/parent/{parentId}/property-uri-template/one-to-ones/{id}', + uriVariables: [ + 'parentId' => new Link(toProperty: 'propertyToOneIriOnly', fromClass: PropertyCollectionIriOnly::class), + 'id' => new Link(fromClass: PropertyUriTemplateOneToOneRelation::class), + ] + ) +] +#[ORM\Entity] +class PropertyUriTemplateOneToOneRelation +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column] + #[NotBlank] + #[Groups('read')] + public string $name = ''; + + #[ORM\OneToOne(inversedBy: 'toOneRelation')] + private ?PropertyCollectionIriOnly $propertyToOneIriOnly = null; + + public function getId(): ?int + { + return $this->id ?? 42; + } + + public function getPropertyToOneIriOnly(): ?PropertyCollectionIriOnly + { + return $this->propertyToOneIriOnly; + } + + public function setPropertyToOneIriOnly(?PropertyCollectionIriOnly $propertyToOneIriOnly): void + { + $this->propertyToOneIriOnly = $propertyToOneIriOnly; + } +} diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php index 2bfd06210b1..802537cd711 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -40,6 +40,7 @@ use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; /** @@ -196,6 +197,8 @@ public function testNormalizeNonExistentProperty(): void $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); $propertyAccessorProphecy->getValue($dummy, 'bar')->willThrow(new NoSuchPropertyException()); + $serializerProphecy = $this->prophesize(Serializer::class); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ (new ApiResource()) @@ -215,6 +218,8 @@ public function testNormalizeNonExistentProperty(): void $resourceMetadataCollectionFactoryProphecy->reveal(), ); + $normalizer->setSerializer($serializerProphecy->reveal()); + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); } From 2aa93a88f62ee0785e45cbf4cfd8c8713bb84f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20H=C3=A9bert?= Date: Mon, 28 Aug 2023 19:40:51 +0200 Subject: [PATCH 2/2] fix: update for guide --- ...rn-the-iri-of-your-resources-relations.php | 25 ++++++++----------- src/Metadata/ApiProperty.php | 4 ++- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/guides/return-the-iri-of-your-resources-relations.php b/docs/guides/return-the-iri-of-your-resources-relations.php index 92602910eb3..ba504b495d3 100644 --- a/docs/guides/return-the-iri-of-your-resources-relations.php +++ b/docs/guides/return-the-iri-of-your-resources-relations.php @@ -15,21 +15,18 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; - use ApiPlatform\State\ProviderInterface; - use Symfony\Component\Serializer\Annotation\Groups; #[ApiResource( operations: [ - new Get(normalizationContext: ['groups' => ['read']], provider: Brand::class), + new Get(provider: Brand::class.'::provide'), ], )] - class Brand implements ProviderInterface + class Brand { public function __construct( #[ApiProperty(identifier: true)] public readonly int $id = 1, - #[Groups('read')] public readonly string $name = 'Anon', // Setting uriTemplate on a relation with a resource collection will try to find the related operation. @@ -38,13 +35,11 @@ public function __construct( * @var array $cars */ #[ApiProperty(uriTemplate: '/brands/{brandId}/cars')] - #[Groups('read')] private array $cars = [], // Setting uriTemplate on a relation with a resource item will try to find the related operation. // It is based on the uriTemplate set on the operation defined on the Address resource (see below). #[ApiProperty(uriTemplate: '/brands/{brandId}/addresses/{id}')] - #[Groups('read')] private ?Address $headQuarters = null ) { @@ -79,9 +74,9 @@ public function setHeadQuarters(?Address $headQuarters): self return $this; } - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - return (new self(1, 'Ford')) + return (new Brand(1, 'Ford')) ->setHeadQuarters(new Address(1, 'One American Road near Michigan Avenue, Dearborn, Michigan')) ->addCar(new Car(1, 'Torpedo Roadster')); } @@ -142,7 +137,6 @@ class Address public function __construct( #[ApiProperty(identifier: true)] public readonly int $id = 1, - #[Groups('read')] public readonly string $name = 'Anon', private ?Brand $brand = null ) @@ -176,30 +170,31 @@ public function setBrand(Brand $brand): void function request(): Request { - return Request::create('/brands/1.jsonld', 'GET'); + return Request::create(uri: '/brands/1', method: 'GET', server: ['HTTP_ACCEPT' => 'application/ld+json']); } } namespace App\Tests { - use ApiPlatform\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\ApiResource\Brand; final class BrandTest extends ApiTestCase { - use TestGuideTrait; public function testResourceExposeIRI(): void { - static::createClient()->request('GET', '/brands/1.jsonld'); + static::createClient()->request('GET', '/brands/1', ['headers' => [ + 'Accept: application/ld+json' + ]]); $this->assertResponseIsSuccessful(); - $this->assertMatchesResourceCollectionJsonSchema(Brand::class, '_api_/brand{._format}_get_item'); + $this->assertMatchesResourceCollectionJsonSchema(Brand::class, '_api_/brands/{id}{._format}_get'); $this->assertJsonContains([ "@context" => "/contexts/Brand", "@id" => "/brands/1", "@type" => "Brand", + "name"=> "Ford", "cars" => "/brands/1/cars", "headQuarters" => "/brands/1/addresses/1" ]); diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 346fd82e2b8..48f7c31751e 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -40,7 +40,7 @@ final class ApiProperty * @param string[] $types the RDF types of this property * @param string[] $iris * @param Type[] $builtinTypes - * @param string|null $uriTemplate whether to return the subRessource collection IRI instead of an iterable of IRI + * @param string|null $uriTemplate (experimental) whether to return the subRessource collection IRI instead of an iterable of IRI */ public function __construct( private ?string $description = null, @@ -469,6 +469,8 @@ public function withGenId(bool $genId): self /** * Whether to return the subRessource collection IRI instead of an iterable of IRI. + * + * @experimental */ public function getUriTemplate(): ?string {