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 = [