Zfcampus/zf-hal adding entity links for collection entity

Using Apigility v. 1.4 I strugle to add additional links to entity of a collection. I have apigility attached to my own services so I do not use the generated entity and collection object. I use the resource listener fetchall directly. I have nested resource /api/sellers/:seller_id/tickets[:/ticket_id] and use following code to generate the hall response. As the link ‘owner’ needs to have dynamically set :seller_id the metadatamap way of injecting links is not usable as these are static. I could not find a way how add any id to the “links” section of metadata. So I tried to use the setEntityLinks() of Collection. I seems that rendering Hal when the embeded object type is Entity, then only links defined in metadata_map are rendered and links set by setEntityLinks() are ignored. Is that correct? Is there any workaround, so that I can inject other links for entities within a collection? Below is code what I have tried, but does not render. Thank you in advance for hints.

indent preformatted text by 4 spaces
 
 public function fetchAll($params = [])
{
    $sellerid = $this->event->getRouteParam('seller_id');
    $seller = $this->entityManager->getRepository(Seller::class)->findOneBysellerid($sellerid);
    $tickets = $this->ticketsManager->getForSeller($seller);

    $ticketsCollection = new Collection($tickets);

    $ownerLink = new Link('owner');
    $ownerLink->setRoute('api.rest.sellers', ['seller_id' => $sellerid]);

    $entityLinkCollection = $ticketsCollection->getEntityLinks();
    if (is_null($entityLinkCollection))
        $entityLinkCollection = new LinkCollection();

    $entityLinkCollection->add($ownerLink);
    $ticketsCollection->setEntityLinks($entityLinkCollection);

    return $ticketsCollection;
}

So the problem is that your route has 2 route parameters (seller_id and ticket_id) and HAL only supports one route parameter? Or do you need to generate 2 links for the same resource?

Thx for quick response. No no. The nesting of resources is not a problem at all. What I need are other _links for the embeded entity within a collection. _self is autogenerated. Bellow is a snipet of the generated response. Note the ‘owners’ link has not ‘seller_id’. I cant find a way how to set that value, which is different for every entity within a collection. I know the value in the resource listener, but I cant find a way how to set it for the links object of entities within the collection object.

{
"_links": {
    "self": {
        "href": "http://127.0.0.1:8090/sellers/2/tickets"
    }
},
"_embedded": {
    "tickets": [
        {
            "ticketcode": "V6AGDE7XW",
            "ticketid": 1709,
            "dateassigned": "2019-10-05 02:45:42",
            "_links": {
                "owners": {
                    "href": "http://127.0.0.1:8090/api/sellers"
                },
                "self": {
                    "href": "http://127.0.0.1:8090/sellers/2/tickets/1709"
                }
            }
        },
.....
   ]
},
"total_items": 86

}

I encountered the same situation. I managed to fix it by creating a new strategy for the given resource (Ticket in your case).

Metadata should look similar:

MetadataMap::class => [
[
class’ => TicketResourceMetadata::class,
‘resource_class’ => Ticket::class,
‘route’ => GetTicketHandler::class,
‘extractor’ => ClassMethodsHydrator::class,
‘resource_identifier’ => ‘id’,
‘route_identifier_placeholder’ => ‘id’,
]
]

‘zend-expressive-hal’ => [
‘resource-generator’ => [
‘strategies’ => [ // The registered strategies and their metadata types
// RouteBasedCollectionMetadata::class => RouteBasedCollectionStrategy::class,
TicketResourceMetadata::class => TicketResourceStrategy::class,
],
],
‘metadata-factories’ => [
TicketResourceMetadata::class => RouteBasedResourceMetadataFactory::class,
],
],

TicketResourceMetadata:

class TicketResourceMetadata
extends AbstractResourceMetadata
{
/** @var string /
private $resourceIdentifier;
/
* @var string /
private $route;
/
* @var string /
private $routeIdentifierPlaceholder;
/
* @var array */
private $routeParams;

public function __construct(
    string $class,
    string $route,
    string $extractor,
    string $resourceIdentifier = 'id',
    string $routeIdentifierPlaceholder = 'id',
    array $routeParams = []
)
{
    $this->class                      = $class;
    $this->route                      = $route;
    $this->extractor                  = $extractor;
    $this->resourceIdentifier         = $resourceIdentifier;
    $this->routeIdentifierPlaceholder = $routeIdentifierPlaceholder;
    $this->routeParams                = $routeParams;
}

public function getRoute(): string
{
    return $this->route;
}

public function getResourceIdentifier(): string
{
    return $this->resourceIdentifier;
}

public function getRouteIdentifierPlaceholder(): string
{
    return $this->routeIdentifierPlaceholder;
}

public function getRouteParams(): array
{
    return $this->routeParams;
}

public function setRouteParams(array $routeParams): void
{
    $this->routeParams = $routeParams;
}

}

class TicketResourceStrategy
implements StrategyInterface
{
use ExtractInstanceTrait;

/**
 * @param object                 $instance
 * @param AbstractMetadata       $metadata
 * @param ResourceGenerator      $resourceGenerator
 * @param ServerRequestInterface $request
 *
 * @return HalResource
 * @throws \InvalidArgumentException
 * @throws UnexpectedMetadataTypeException
 */
public function createResource(
    $instance,
    AbstractMetadata $metadata,
    ResourceGenerator $resourceGenerator,
    ServerRequestInterface $request
): HalResource
{
    if (!$metadata instanceof AppInstanceResourceMetadata) {
        throw UnexpectedMetadataTypeException::forMetadata(
            $metadata,
            self::class,
            AppInstanceResourceMetadata::class
        );
    }

    $data = $this->extractInstance(
        $instance,
        $metadata,
        $resourceGenerator,
        $request
    );

    $routeParams        = $metadata->getRouteParams();
    $resourceIdentifier = $metadata->getResourceIdentifier();
    $routeIdentifier    = $metadata->getRouteIdentifierPlaceholder();

    if (isset($data[$resourceIdentifier])) {
        $routeParams[$routeIdentifier] = $data[$resourceIdentifier];
    }

    return new HalResource($data, [
        $resourceGenerator->getLinkGenerator()->fromRoute(
            'self',
            $request,
            $metadata->getRoute(),
            $routeParams,
        ),
        $resourceGenerator->getLinkGenerator()->fromRoute(
            'owners',
            $request,
            'owners.route.name',
            ['id' => $data['seller_id']]
        ),
    ]);
}

}

Notice the ‘owners’ route from the last class. So if you add theese to your project, each time the Ticket entity is extracted, this strategy is used and you can add as many routes as you want. For instance, when building an API for listing items, I always add the urls for CRUD operations

I am not sure I can apply this as I am not using expressive, but ZF3 mvc.

Yes its different in ZF3 and ZE. I believe the correct question is, why the links are not rendered in Entity within Collection. There is explicitly named method which I believe shall simplify inejcting default links for entity and that is

class Collection implements Link\LinkCollectionAwareInterface
{
...
/**
 * Set default set of links to use for entities
 *
 * @param  Link\LinkCollection $links
 * @return self
 */
public function setEntityLinks(Link\LinkCollection $links)
{
    $this->entityLinks = $links;
    return $this;
}

/**
 * Set default set of links to use for entities
 *
 * Deprecated; please use setEntityLinks().
 *
 * @deprecated
 * @param  Link\LinkCollection $links
 * @return self
 */
public function setResourceLinks(Link\LinkCollection $links)
{
    trigger_error(sprintf(
        '%s is deprecated; please use %s::setEntityLinks',
        __METHOD__,
        __CLASS__
    ), E_USER_DEPRECATED);
    return $this->setEntityLinks($links);
}
...

But these are not rendered, because these are not set in the entity before rendering. These links seem to be ignored. The key part seems to be Hal plugin and its method extractCollection

 /**
 * Extract a collection as an array
 *
 * @todo   Remove 'resource' from event parameters for 1.0.0
 * @todo   Remove trigger of 'renderCollection.resource' for 1.0.0
 * @param  Collection $halCollection
 * @param  int $depth                   depth of the current rendering recursion
 * @param  int $maxDepth                maximum rendering depth for the current metadata
 * @return array
 */
protected function extractCollection(Collection $halCollection, $depth = 0, $maxDepth = null)
{
...
// here are extracted the entities
foreach ($halCollection->getCollection() as $entity) {
        $eventParams = new ArrayObject([
            'collection'   => $halCollection,
            'entity'       => $entity,
            'resource'     => $entity,
            'route'        => $entityRoute,
            'routeParams'  => $entityRouteParams,
            'routeOptions' => $entityRouteOptions,
        ]);
        $events->trigger('renderCollection.resource', $this, $eventParams);
        $events->trigger('renderCollection.entity', $this, $eventParams);

        $entity = $eventParams['entity'];

        if (is_object($entity) && $metadataMap->has($entity)) {
            $entity = $this->getResourceFactory()->createEntityFromMetadata($entity, $metadataMap->get($entity));
        }

        if ($entity instanceof Entity) {
            // Depth does not increment at this level
            $collection[] = $this->renderEntity($entity, $this->getRenderCollections(), $depth, $maxDepth);
            continue;
        }
       ...

In case the original entity (Ticket in my case) is object (true) and has metadata (true) then the Entity is recreated with the original entity included in the new object. The new entity has links, but only these named in the metadatamap. Then in the last if its rendered without any chance to inject the $entityLinks from the parent Collection. It looks like a bug to me.

Wha I am missing here is

$entity->setLinks($halCollection->getEntityLinks());

this should go right after the

if (is_object($entity) && $metadataMap->has($entity)) {
            $entity = $this->getResourceFactory()->createEntityFromMetadata($entity, $metadataMap->get($entity));
        }

In that case I could add any links from the resource listener as I have used it.

Just in case anyone would look for solution how to add relations into Collections Entities.
I did two things:

  1. used the event handler as described in the apigility documentation with the difference that I attach the event handler in resource factory as follows.

    class TicetsResourceFactory
    {
    /**

    • @param $services

    • @return TicketsResource
      */
      public function __invoke($services)
      {
      $ticketsResource = new TicketsResource(
      $services->get(TicketManager::class),
      $services->get(‘doctrine.entitymanager.orm_specific’)
      );

      $event = $services->get(‘ViewHelperManager’)
      ->get(‘Hal’)
      ->getEventManager();

      $event->attach(‘renderEntity’, [$ticketsResources, ‘onRenderEntity’]);

      return $ticketsResources;
      }
      }

Then the TicketsResource has the method handling injection of links.
`public function onRenderEntity(Event $e)
{
$entity = $e->getParam(‘entity’);
if (!$entity->getEntity() instanceof Ticket) {
return;
}

    $ticket = $entity->getEntity();

    // Add a "ticketowner" relational link if ticket has one
    if (!is_null($ticket->getTicketowner()))
        $entity->getLinks()->add(\ZF\Hal\Link\Link::factory([
            'rel' => 'ticketowner',
            'route' => [
                'name' => 'api.rest.owners',
                'params' => [
                    'owner_id' => $ticket->getTicketowner()->getTicketownerid(),
                ],
            ],
        ]));
}`
  1. in order to make use of the combination zf\hal\Collection->getEntityLinks()
    zf\hal\Collection->setEntityLinks() I have modified the zf\Hal\Plugin\Hal

          protected function extractCollection(Collection $halCollection, $depth = 0, $maxDepth = null)
         {
          ...
    
          if (is_object($entity) && $metadataMap->has($entity)) {
                 $entity = $this->getResourceFactory()->createEntityFromMetadata($entity, $metadataMap->get($entity));
             }
    
             if ($entity instanceof Entity) {
                 /**
                  *  fix to inject Links from Collection into entity
                  */
                 if (!is_null($halCollection->getEntityLinks())) {
                     $entityLinks = $entity->getLinks();
                     foreach ($halCollection->getEntityLinks() as $collectionsEntityLink)
                         $entityLinks->idempotentAdd($collectionsEntityLink);
                 }
                 // Depth does not increment at this level
                 $collection[] = $this->renderEntity($entity, $this->getRenderCollections(), $depth, $maxDepth);
                 continue;
             }
    

This allows me to inejct relations with ids known both on collection level using $collection->setEntityLinks($linksCollection) as well as on Entity level using the event handler. As for the fix in Hal Plugin I have submitted it on GitHub for comments. Lets see.