diff --git a/features/hal/collection_uri_template.feature b/features/hal/collection_uri_template.feature
new file mode 100644
index 00000000000..f510739fcba
--- /dev/null
+++ b/features/hal/collection_uri_template.feature
@@ -0,0 +1,52 @@
+@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
+
+ 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"
+ }
+ },
+ "_embedded": {
+ "propertyCollectionIriOnlyRelation": [
+ {
+ "_links": {
+ "self": {
+ "href": "/property_collection_iri_only_relations/1"
+ }
+ },
+ "name": "relation"
+ }
+ ],
+ "iterableIri": [
+ {
+ "_links": {
+ "self": {
+ "href": "/property_collection_iri_only_relations/9999"
+ }
+ },
+ "name": "Michel"
+ }
+ ]
+ }
+ }
+ """
diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature
index d2ac3db1323..5292a4e0e48 100644
--- a/features/jsonld/iri_only.feature
+++ b/features/jsonld/iri_only.feature
@@ -56,3 +56,37 @@ Feature: JSON-LD using iri_only parameter
"hydra:totalItems": 3
}
"""
+
+ 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 valid according to this schema:
+ """
+ {
+ "hydra:member": [
+ {
+ "@id": "/property_collection_iri_onlies/1",
+ "@type": "PropertyCollectionIriOnly",
+ "propertyCollectionIriOnlyRelation": "/property-collection-relations",
+ "iterableIri": "/parent/1/another-collection-operations"
+ }
+ ]
+ }
+ """
+ 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 valid according to this schema:
+ """
+ {
+ "@context": "/contexts/PropertyCollectionIriOnly",
+ "@id": "/property_collection_iri_onlies/1",
+ "@type": "PropertyCollectionIriOnly",
+ "propertyCollectionIriOnlyRelation": "/property-collection-relations",
+ "iterableIri": "/parent/1/another-collection-operations"
+ }
+ """
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..a1d8aa6e751 100644
--- a/src/Hal/Serializer/ItemNormalizer.php
+++ b/src/Hal/Serializer/ItemNormalizer.php
@@ -14,6 +14,7 @@
namespace ApiPlatform\Hal\Serializer;
use ApiPlatform\Api\UrlGeneratorInterface;
+use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\Serializer\AbstractItemNormalizer;
use ApiPlatform\Serializer\CacheKeyTrait;
@@ -166,7 +167,28 @@ private function getComponents(object $object, ?string $format, array $context):
continue;
}
- $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many'];
+ $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many', 'uriTemplate' => null];
+
+ // if we specify the uriTemplate, generates its value for link definition
+ // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
+ if ($isMany && $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,
+ forceCollection: true,
+ httpOperation: true
+ );
+
+ if ($operation instanceof GetCollection) {
+ $relation['uriTemplate'] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
+ }
+ }
+
+
if ($propertyMetadata->isReadableLink()) {
$components['embedded'][] = $relation;
}
@@ -215,6 +237,12 @@ private function populateRelation(array $data, object $object, ?string $format,
$relationName = $this->nameConverter->normalize($relationName, $class, $format, $context);
}
+ // if we specify the uriTemplate, then the link takes the uriTemplate defined.
+ if ('links' === $type && $itemUriTemplate = $relation['uriTemplate']) {
+ $data[$key][$relationName]['href'] = $itemUriTemplate;
+ continue;
+ }
+
if ('one' === $relation['cardinality']) {
if ('links' === $type) {
$data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue);
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 5f3538d1600..b2a86187762 100644
--- a/src/Serializer/AbstractItemNormalizer.php
+++ b/src/Serializer/AbstractItemNormalizer.php
@@ -30,8 +30,6 @@
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\Type;
-use Symfony\Component\Serializer\Encoder\CsvEncoder;
-use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
@@ -615,10 +613,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() ?? [];
@@ -630,13 +626,24 @@ protected function getAttributeValue(object $object, string $attribute, string $
&& ($className = $collectionValueType->getClassName())
&& $this->resourceClassResolver->isResourceClass($className)
) {
+ $childContext = $this->createChildContext($context, $attribute, $format);
+ unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
+
+ // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content
+ if ($format === 'jsonld' && $itemUriTemplate = $propertyMetadata->getUriTemplate()) {
+ $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
+ operationName: $itemUriTemplate,
+ 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($context, $attribute, $format);
- unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
return $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
}
@@ -645,17 +652,29 @@ protected function getAttributeValue(object $object, string $attribute, string $
($className = $type->getClassName())
&& $this->resourceClassResolver->isResourceClass($className)
) {
+ $childContext = $this->createChildContext($context, $attribute, $format);
+ $childContext['resource_class'] = $className;
+ unset($childContext['iri'], $childContext['uri_variables']);
+
+ if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
+ $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(
+ operationName: $itemUriTemplate,
+ 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($context, $attribute, $format);
$childContext['resource_class'] = $resourceClass;
if ($this->resourceMetadataCollectionFactory) {
$childContext['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
}
- unset($childContext['iri'], $childContext['uri_variables']);
return $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext);
}
@@ -674,6 +693,8 @@ protected function getAttributeValue(object $object, string $attribute, string $
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['force_resource_class']);
$childContext['output']['gen_id'] = $propertyMetadata->getGenId() ?? true;
+ $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
+
return $this->serializer->normalize($attributeValue, $format, $childContext);
}
@@ -681,6 +702,8 @@ protected function getAttributeValue(object $object, string $attribute, string $
$childContext = $this->createChildContext($context, $attribute, $format);
unset($childContext['iri'], $childContext['uri_variables']);
+ $attributeValue = $this->propertyAccessor->getValue($object, $attribute);
+
return $this->serializer->normalize($attributeValue, $format, $childContext);
}
}
@@ -692,6 +715,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/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php
index fa4facf60b4..42646214eac 100644
--- a/tests/Behat/DoctrineContext.php
+++ b/tests/Behat/DoctrineContext.php
@@ -76,6 +76,8 @@
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\Question as QuestionDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument;
@@ -159,6 +161,8 @@
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\Question;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
@@ -1949,6 +1953,22 @@ public function thereAreIriOnlyDummies(int $nb): void
$this->manager->flush();
}
+ /**
+ * @Given there are propertyCollectionIriOnly with relations
+ */
+ public function thereAreIriOnlyCollections(): void
+ {
+ $propertyCollectionIriOnlyRelation = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument();
+ $propertyCollectionIriOnlyRelation->name = 'relation';
+
+ $propertyCollectionIriOnly = $this->isOrm() ? new PropertyCollectionIriOnly() : new PropertyCollectionIriOnlyDocument();
+ $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation);
+
+ $this->manager->persist($propertyCollectionIriOnly);
+ $this->manager->persist($propertyCollectionIriOnlyRelation);
+ $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/TestBundle/Document/PropertyCollectionIriOnly.php b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php
new file mode 100644
index 00000000000..b7daf0b7404
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnly.php
@@ -0,0 +1,99 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\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.
+ */
+#[Get(normalizationContext: ['groups' => ['read']]), GetCollection(normalizationContext: ['groups' => ['read']]), Post]
+#[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 = [];
+
+ 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;
+ }
+}
diff --git a/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php
new file mode 100644
index 00000000000..34623024577
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Document/PropertyCollectionIriOnlyRelation.php
@@ -0,0 +1,62 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\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(fromProperty: 'propertyCollectionIriOnly', fromClass: PropertyCollectionIriOnly::class),
+ ]
+ )
+]
+#[ODM\Document]
+class PropertyCollectionIriOnlyRelation
+{
+ /**
+ * The entity ID.
+ */
+ #[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/Entity/PropertyCollectionIriOnly.php b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnly.php
new file mode 100644
index 00000000000..65c6424feb7
--- /dev/null
+++ b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnly.php
@@ -0,0 +1,105 @@
+
+ *
+ * For 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 = [];
+
+ 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;
+ }
+}
diff --git a/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnlyRelation.php b/tests/Fixtures/TestBundle/Entity/PropertyCollectionIriOnlyRelation.php
new file mode 100644
index 00000000000..51bf99826d9
--- /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(fromProperty: '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/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php
index 339b3ec8480..e998e003037 100644
--- a/tests/Serializer/AbstractItemNormalizerTest.php
+++ b/tests/Serializer/AbstractItemNormalizerTest.php
@@ -15,10 +15,16 @@
use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Api\ResourceClassResolverInterface;
+use ApiPlatform\Api\UrlGeneratorInterface;
use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GetCollection;
+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\Serializer\AbstractItemNormalizer;
use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5584\DtoWithNullValue;
@@ -27,6 +33,8 @@
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NonCloneableDummy;
+use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly;
+use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy;
use Doctrine\Common\Collections\ArrayCollection;
@@ -223,6 +231,80 @@ public function testNormalizeWithSecuredProperty(): void
]));
}
+ public function testNormalizeCollectionPropertyAsStringOnIriOnly(): void
+ {
+ $propertyCollectionIriOnlyRelation = new PropertyCollectionIriOnlyRelation();
+ $propertyCollectionIriOnlyRelation->name = 'My Relation';
+
+ $propertyCollectionIriOnly = new PropertyCollectionIriOnly();
+ $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation);
+
+ $collectionOperation = new GetCollection('/property-collection-relations');
+
+ $resourceRelationMetadataCollection = new ResourceMetadataCollection(PropertyCollectionIriOnlyRelation::class, [
+ (new ApiResource())->withOperations(new Operations([$collectionOperation])),
+ ]);
+
+ $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'])
+ );
+
+ $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)),
+ ])
+ );
+
+ $iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
+ $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_URL, null, Argument::any())->willReturn('/property-collection-relations');
+ $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_PATH, Argument::type(GetCollection::class), Argument::any())->willReturn('/property-collection-relations');
+
+ $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
+ $propertyAccessorProphecy->getValue($propertyCollectionIriOnly, 'propertyCollectionIriOnlyRelation')->willReturn([$propertyCollectionIriOnlyRelation]);
+
+ $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
+
+ $resourceClassResolverProphecy->isResourceClass(PropertyCollectionIriOnly::class)->willReturn(true);
+ $resourceClassResolverProphecy->getResourceClass($propertyCollectionIriOnly, null)->willReturn(PropertyCollectionIriOnly::class);
+ $resourceClassResolverProphecy->getResourceClass(null, PropertyCollectionIriOnly::class)->willReturn(PropertyCollectionIriOnly::class);
+
+ $resourceClassResolverProphecy->isResourceClass(PropertyCollectionIriOnlyRelation::class)->willReturn(true);
+ $resourceClassResolverProphecy->getResourceClass([$propertyCollectionIriOnlyRelation], null)->willReturn(PropertyCollectionIriOnlyRelation::class);
+ $resourceClassResolverProphecy->getResourceClass(null, PropertyCollectionIriOnlyRelation::class)->willReturn(PropertyCollectionIriOnlyRelation::class);
+ $resourceClassResolverProphecy->getResourceClass([$propertyCollectionIriOnlyRelation], PropertyCollectionIriOnlyRelation::class)->willReturn(PropertyCollectionIriOnlyRelation::class);
+
+ $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [
+ $propertyNameCollectionFactoryProphecy->reveal(),
+ $propertyMetadataFactoryProphecy->reveal(),
+ $iriConverterProphecy->reveal(),
+ $resourceClassResolverProphecy->reveal(),
+ $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',
+ ];
+
+ $this->assertSame($expected, $normalizer->normalize($propertyCollectionIriOnly, null, [
+ 'resources' => [],
+ 'root_operation' => new GetCollection('/property-collection-relations'),
+ ]));
+ }
+
public function testDenormalizeWithSecuredProperty(): void
{
$data = [