Nested resources in URI using Laminas API tools

Hi,
please I would need help with following.

I need to create URI in form /seller/[:sellerId]/tickets[/:ticketId] responding to GET, POST both on individual resource as well as collection.

The resource that the Controller is handling is ticket while the sellerId is manadatory in route parameter.
If I make GET /seller/1/tickets then the resouce fetch($id) method is called where the id is 1. The expected behaviour is that method fetchAll() is called and still I will have the sellerId available via getRouteParams.
Is it doable with laminas api tools? If yes how?
Thank you in advance.

Hey @vlx,

it is absolutely doable with the Laminas API tools. You have to think in resources when handling API endpoints. A resource handles only a single entity / a collection of the same entity.

As you said you want to get a seller and all its tickets in a relational collection. Foloowing this purpose you have two resources here. On the one hand you have a seller resource and on the other hand you have a ticket resource. A seller can have many tickets. Many tickets can have a seller. This is a one to many bidirectional relation.

Relations in your entity

Because of the relation of sellers to tickets I 'd recommend using an ORM system like Doctrine. There you can define such a relation directly in the entity. Think of the following example.

<?php
declare(strict_types=1);
namespace Marcel\V1\Rest\Seller;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Marcel\V1\Rest\Ticket\Ticket;

#[ORM\Entity()]
#[ORM\Table(name: "seller")]
class Seller
{
    #[ORM\Id, ORM\Column(name: "id", type: "bigint"), ORM\GeneratedValue()]
    protected int $id;

    #[ORM\OneToMany(targetEntity: Tickets::class, mappedBy: "seller", cascade: [ "persist", "remove" ])]
    protected Collection $tickets;

    public function __construct()
    {
        $this->tickets = new ArrayCollection();
    }

    // getters and setters follow here
}

That 's our seller entity. As you can see every seller can hold a collection of many tickets. The relation from seller to tickets is defined as a one seller to many tickets relation.

<?php
declare(strict_types=1);
namespace Marcel\V1\Rest\Ticket;

use Doctrine\ORM\Mapping as ORM;
use Marcel\V1\Rest\Seller\Seller;

#[ORM\Entity()]
#[ORM\Table(name: "ticket")]
class Ticket
{
    #[ORM\Id, ORM\Column(name: "id", type: "bigint"), ORM\GeneratedValue()]
    protected int $id;

    #[ORM\ManyToOne(target: Seller::class, inverseBy: "tickets", cascade: [ "persist" ])]
    #[ORM\JoinColumn(name: "seller_id", referencedColumnName: "id", nullable: false)]
    protected Seller $seller;

    // getters and setters go here
}

Above you see the ticket entity. It holds a seller property. This is the owning side. A ticket is always owned by a seller. You can reach the seller always from the ticket itself.

The above shown two entities result in two different REST recources.

  • {{host}}/seller[/:id]
  • {{host}}/ticket[/:id]

Relation in action

By now as we know the above shown recources you can request a single seller by id.

{{host}}/seller/1

We know already, that we will get all informations about the seller whose primary key is “1” in the database. Doctrine ORM has a default lazy loading setting on to many relations. Because of the lazy loading you won 't get all the tickets in the response, as long as you do not hydrate them by yourself or set the fetch attribute to eager. Fetching the to many relation with eager, you will retrieve all tickets for this seller in the REST response.

When not fetching with eager, you possibly create a RPC service. The only task for this service is to response with all tickets by a specific seller. Think of an url like this: {{host}}/seller/:id/tickets.

In the RPC controller you define an indexAction that fetches all the tickets by the given seller.

<?php
declare(strict_types=1);
namespace Marcel\V1\Rpc\Seller;

use Doctrine\Persistence\ObjectManager;
use DoctrineModule\Persistence\ObjectManagerAwareInterface;
use Laminas\Mvc\Controller\AbstractActionController;
use Laminas\ApiTools\ContentNegotiation\ViewModel;

class SellerController extends AbstractActionController implements ObjectManagerAwareInterface
{
    protected ObjectManager $objectManager;

    public function indexAction(): ViewModel
    {
        $sellerId = $this->params()->fromRoute('id', null);

        if ($sellerId === null) {
            // error handling because of null primary key (422)
        }

        $seller = $this->getObjectManager()
            ->getRepository(Seller:class)
            ->find($id)

        if ($seller === false) {
            // error handling because no seller found (404)
        }

        return new ViewModel([
            'payload' => $seller->getTickets(),
        ]);
    }

    public function getObjectManager(): ObjectManager
    {
        return $this->objectManager;
    }

    public function setObjectManager(ObjectManager $objectManager): void
    {
        $this->objectManager = $objectManager;
    }
}

Conclusion

Sure, this might not be the only way to get relations done. But Doctrine offers you a ready to use object relational mapper that overtakes a lot of work for you. The above shown solution just uses two resources and a one to many relation between the two resources. From my point of view you could not do it any easier. Sure, you can code all that for yourself. But keep in mind thinking in resources. That 's the most important thing when it comes to understanding API endpoints.

Thank you @ezkimo
My issue was slightly different and that is whether I can have two ID fields in one REST request and still benefit from the AbstractResourceListener of api tools.

I see now my API design is wrong. The correct URIs have to be:

/sellers[/:sellerId]
/sellers/:sellerId/tickets
/tickets/:ticketId

With that, there is no need to have two Ids in one request. Returning using the HAL will work as well fine.
I am using Doctrine ORM. I will check the eager loading. It looks like for laminas Doctrine is the way to go as laminas-db does not have maintainers.

Thanks for help. Still if you have any thougths about having two IDs in one REST request in connection to the AbstractResourceController, it might clarify how that would work. Even thou it does not make sense form the design perspective would URI like this work and how with AbstractResourceController?
/sellers/:sellerId/tickets/:ticketId

best regards

Vlado

I don’t recommend you long URI like the one you want to do. At the maximum you should get 3 parts in your endpoint such as /a/b/c.

So what I would recommend you is to change your URI as follow:

/ticket?seller_id=:sellerId
In this case you use a sellerId as a filter in the list of ticket.

OR
/ticket/:ticketId
Why do you need the seller id in the URI if you already have a unique id for your ticket ?
(maybe you dont use UUID for your IDs ? if not start to use uuid for security purpose!! )

Cheers,
Gary