diff --git a/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php b/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php index 326197a67a..fe57060406 100644 --- a/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php +++ b/api/src/Serializer/Normalizer/RelatedCollectionLinkNormalizer.php @@ -8,6 +8,7 @@ use ApiPlatform\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use App\Entity\BaseEntity; @@ -81,6 +82,11 @@ class RelatedCollectionLinkNormalizer implements NormalizerInterface, Serializer use PropertyHelperTrait; use ClassInfoTrait; + /** + * @var (Operation|string)[] + */ + private array $exactSearchFilterExistsOperationCache = []; + public function __construct( private NormalizerInterface $decorated, private ServiceLocator $filterLocator, @@ -111,12 +117,17 @@ public function normalize($data, $format = null, array $context = []): null|arra continue; } - try { - $normalized_data['_links'][$rel] = ['href' => $this->getRelatedCollectionHref($data, $rel, $context)]; - } catch (UnsupportedRelationException $e) { - // The relation is not supported, or there is no matching filter defined on the related entity + // If relation is a public property, this property can be checked to be a non-null value + $values = get_object_vars($data); + if (array_key_exists($rel, $values) && null == $values[$rel]) { + // target-value is NULL + continue; + } + + if (!$this->getRelatedCollectionHref($data, $rel, $context, $result)) { continue; } + $normalized_data['_links'][$rel] = ['href' => $result]; } return $normalized_data; @@ -136,7 +147,7 @@ public function setSerializer(SerializerInterface $serializer): void { } } - public function getRelatedCollectionHref($object, $rel, array $context = []): string { + protected function getRelatedCollectionHref($object, $rel, array $context, &$href): bool { $resourceClass = $this->getObjectClass($object); if ($this->nameConverter instanceof NameConverterInterface) { @@ -149,7 +160,9 @@ public function getRelatedCollectionHref($object, $rel, array $context = []): st $params = $this->extractUriParams($object, $annotation->getParams()); [$uriTemplate] = $this->uriTemplateFactory->createFromResourceClass($annotation->getRelatedEntity()); - return $this->uriTemplate->expand($uriTemplate, $params); + $href = $this->uriTemplate->expand($uriTemplate, $params); + + return true; } try { @@ -161,7 +174,8 @@ public function getRelatedCollectionHref($object, $rel, array $context = []): st $relationMetadata = $classMetadata->getAssociationMapping($rel); } catch (MappingException) { - throw new UnsupportedRelationException($resourceClass.'#'.$rel.' is not a Doctrine association. Embedding non-Doctrine collections is currently not implemented.'); + // $resourceClass # $rel is not a Doctrine association. Embedding non-Doctrine collections is currently not implemented + return false; } $relatedResourceClass = $relationMetadata['targetEntity']; @@ -170,21 +184,38 @@ public function getRelatedCollectionHref($object, $rel, array $context = []): st $relatedFilterName ??= $relationMetadata['inversedBy']; if (empty($relatedResourceClass) || empty($relatedFilterName)) { - throw new UnsupportedRelationException('The '.$resourceClass.'#'.$rel.' relation does not have both a targetEntity and a mappedBy or inversedBy property'); + // The $resourceClass # $rel relation does not have both a targetEntity and a mappedBy or inversedBy property + return false; } - $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($relatedResourceClass); - $operation = OperationHelper::findOneByType($resourceMetadataCollection, GetCollection::class); + $lookupKey = $relatedResourceClass.':'.$relatedFilterName; + if (isset($this->exactSearchFilterExistsOperationCache[$lookupKey])) { + $result = $this->exactSearchFilterExistsOperationCache[$lookupKey]; + } else { + $result = 'No Operation'; + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($relatedResourceClass); + $operation = OperationHelper::findOneByType($resourceMetadataCollection, GetCollection::class); - if (!$operation) { - throw new UnsupportedRelationException('The resource '.$relatedResourceClass.' does not implement GetCollection() operation.'); + if (!$operation) { + // The resource $relatedResourceClass does not implement GetCollection() operation + } else { + $filterExists = $this->exactSearchFilterExists($relatedResourceClass, $relatedFilterName); + if (!$filterExists) { + // The resource $relatedResourceClass does not have a search filter for the relation $relatedFilterName + } else { + $result = $operation; + } + } + $this->exactSearchFilterExistsOperationCache[$lookupKey] = $result; } - if (!$this->exactSearchFilterExists($relatedResourceClass, $relatedFilterName)) { - throw new UnsupportedRelationException('The resource '.$relatedResourceClass.' does not have a search filter for the relation '.$relatedFilterName.'.'); + if ($result instanceof Operation) { + $href = $this->router->generate($result->getName(), [$relatedFilterName => urlencode($this->iriConverter->getIriFromResource($object))], UrlGeneratorInterface::ABS_PATH); + + return true; } - return $this->router->generate($operation->getName(), [$relatedFilterName => urlencode($this->iriConverter->getIriFromResource($object))], UrlGeneratorInterface::ABS_PATH); + return false; } protected function getRelatedCollectionLinkAnnotation(string $className, string $propertyName): ?RelatedCollectionLink {