api-platform / api-platform

🕸️ Create REST and GraphQL APIs, scaffold Jamstack webapps, stream changes in real-time.
https://api-platform.com
MIT License
8.68k stars 962 forks source link

[Question] How to display extra fields from a ManyToMany relation table when requesting a sub resource #1230

Open xavier-rodet opened 5 years ago

xavier-rodet commented 5 years ago

I need to display the "skill" of the "drivers" of specific "cars", which comes from a relational table "Relation_Car_Driver", this field should only appear for sub-resources calls "/cars/:id/drivers" and "/drivers/:id/cars"

I have 3 Tables :

My main API resources are :

My API sub resources are :

For those sub-resources calls i want to return the "skill" value coming from their relation !

Entity Car :

# src/Entity/Car.php
namespace App\Entity;

/**
 * @ORM\Table(name="Car")
 * @ORM\Entity
 *
 * @ApiResource(
 *     subresourceOperations={
 *          "drivers_get_subresource"={
 *              "method"="GET",
 *              "path"="/cars/{id}/drivers"
 *          }
 *      }
 * )
 */
class Car
{
// ...
    /**
     * @ORM\ManyToMany(targetEntity="App\Entity\Driver", inversedBy="cars")
     * @JoinTable(name="Relation_Car_Driver",
     *      joinColumns={@JoinColumn(name="car_id", referencedColumnName="id")},
     *     inverseJoinColumns={@JoinColumn(name="driver_id", referencedColumnName="id")}
     * )
     * @ApiSubresource(maxDepth=1)
     */
    private $drivers;
}

Entity Driver :

# src/Entity/Driver.php
namespace App\Entity;

/**
 * @ORM\Table(name="Driver")
 * @ORM\Entity
 *
 * @ApiResource(
 *     subresourceOperations={
 *          "cars_get_subresource"={
 *              "method"="GET",
 *              "path"="/drivers/{id}/cars"
 *          }
 *      }
 * )
 */
class Driver
{
// ...
    /**
     * @ORM\ManyToMany(targetEntity="App\Entity\Car", inversedBy="drivers")
     * @JoinTable(name="Relation_Car_Driver",
     *      joinColumns={@JoinColumn(name="driver_id", referencedColumnName="id")},
     *     inverseJoinColumns={@JoinColumn(name="car_id", referencedColumnName="id")}
     * )
     * @ApiSubresource(maxDepth=1)
     */
    private $cars;
}

Entity Relation_Car_Driver :

# src/Entity/RelactionCarDriver.php
namespace App\Entity;

/**
 * RelationCarDriver
 *
 * @ORM\Table(name="Relation_Car_Driver")
 * @ORM\Entity
 */
class RelationCarDriver
{
// ..
    /**
     * @var int|null
     *
     * @ORM\Column(name="skill", type="int", nullable=true)
     */
    private $skill;
}

Actual result from GET /drivers/:id/cars :

{
  "@context": "/api/contexts/Car",
  "@id": "/api/drivers/1/cars",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/api/cars/1",
      "@type": "Car",
      "id": 1,
      "model": "Mustang"
    },
    {
      "@id": "/api/cars/2",
      "@type": "Car",
      "id": 2,
      "model": "Chevrolet"
    }
  ],
  "hydra:totalItems": 2
}

Expected result from GET /drivers/:id/cars :

{
  "@context": "/api/contexts/Car",
  "@id": "/api/drivers/1/cars",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/api/cars/1",
      "@type": "Car",
      "id": 1,
      "model": "Mustang",
      "skill": 63
    },
    {
      "@id": "/api/cars/2",
      "@type": "Car",
      "id": 2,
      "model": "Chevrolet",
      "skill": 13
    }
  ],
  "hydra:totalItems": 2
}

I have no clue how to achieve that ? Thanks

piotrmus commented 5 years ago

@xavier-rodet What you are trying to do is not in line with the REST assumptions. Skill Parameter is not included in Car resurce. You should create endpoint for resource RelationCarDriver and in list create Filters for Car and Driver. On this list you can return subresources Car and Driver.

For example: GET /relation_car_drivers?cars.id[]=2 :

{
  "@context": "/api/contexts/RelationCarDriver",
  "@id": "/api/relation_car_drivers",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/api/relation_car_drivers/1",
      "@type": "RelationCarDriver",
      "id": 1,
      "car": {
          "@id": "/api/cars/2",
          "@type": "Car",
          "id": 2,
          "model": "Chevrolet"
    },
      "driver": {
          "@id": "/api/driver/7",
          "@type": "Driver",
          "id": 7,
          "name": "Kubica"
    },
      "skill": 63
    }
  ],
  "hydra:totalItems": 1
}
xavier-rodet commented 5 years ago

Yes you're right, i realized it myself after a while ...

But i don't have any interest to access this from relation_car_driver resource, i prefer accessing relation_car_driver from /cars/id/drivers and /drivers/id/cars sub-resources !

Let me share what i've done to achieve that :

See my code below :

Entity Car :

<?php
# src/Entity/Car.php
namespace App\Entity;

/**
* @ORM\Table(name="Car")
* @ORM\Entity(repositoryClass="App\Repository\CarRepository")
*
* @ApiResource(
*      denormalizationContext={"groups"={"car:write"}},
*      normalizationContext={"groups"={"car:read"}},
*
*      collectionOperations = {
*          "post",
*          "get",
*          "get_driver_cars"={
*              "method"="GET",
*              "path"="/drivers/{id}/cars",
*              "controller"=App\Controller\ReadDriverCars::class,
*              "read"=false,
*              "normalization_context"={"groups"={"car:read", "driverCar:read"}}
*          }
*      },
*
*     itemOperations={
*          "get",
*          "delete",
*     },
* )
 */
class Car
{
    /**
     * @ORM\OneToMany(targetEntity="App\Entity\RelationCarDriver", mappedBy="car", orphanRemoval=true, cascade={"persist"})
     */
    private $relationsCarDriver;

   // This is added manually to access our relation extra fields (with setters & getters)
   /**
    * @Groups({"driverCar:read"})
    */
   private $relationCarDriver;

 // ...
}

Entity Driver :

<?php
# src/Entity/Driver.php
namespace App\Entity;

/**
* @ORM\Table(name="Driver")
* @ORM\Entity(repositoryClass="App\Repository\DriverRepository")
*
* @ApiResource(
*      denormalizationContext={"groups"={"driver:write"}},
*      normalizationContext={"groups"={"driver:read"}},
*
*      collectionOperations = {
*          "post",
*          "get",
*          "get_car_drivers"={
*              "method"="GET",
*              "path"="/cars/{id}/drivers",
*              "controller"=App\Controller\ReadCarDrivers::class,
*              "read"=false,
*              "normalization_context"={"groups"={"carDriver:read", "carDriver:read"}}
*          }
*      },
*
*     itemOperations={
*          "get",
*          "delete",
*     },
* )
 */
class Driver
{
    /**
     * @ORM\OneToMany(targetEntity="App\Entity\RelationCarDriver", mappedBy="driver", orphanRemoval=true, cascade={"persist"})
     */
    private $relationsCarDriver;

   // This is added manually to access our relation extra fields (with setters & getters)
   /**
    * @Groups({"carDriver:read"})
    */
   private $relationCarDriver;

// ...
}

Entity RelationCarDriver :

<?php

namespace App\Entity;

/**
 * RelationCarDriver
 *
 * @ORM\Table(name="Relation_Car_Driver")
 * @ORM\Entity
 *
 * @ApiResource(
 *
 *     collectionOperations = {},
 *     itemOperations={
 *          "get"={
 *              "method"="GET",
 *              "path"="/relations_game_player/{id}",
 *           },
 *      },
 * )
 */
class RelationCarDriver
{
    /**
     * @ORM\Column(name="skill", type="integer", nullable=false)
     * @Groups({"carDriver:read", "driverCar:read"})
     */
    private $skill;

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\Car", inversedBy="relationsCarDriver")
     * @ORM\JoinColumn(name="car_id", nullable=false)
     */
    private $car;

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\Driver", inversedBy="relationsCarDriver")
     * @ORM\JoinColumn(name="driver_id", nullable=false)
     */
    private $driver;

    // ...
}

Custom action for /drivers/:id/cars :

<?php
# src/Controller/ReadDriverCars.php
namespace App\Controller;

class ReadDriverCars
{
    private $carRepository;

    public function __construct(CarRepository $carRepository)
    {
        $this->carRepository= $carRepository;
    }
    public function __invoke(Request $data) : Paginator
    {
        $driverId = (int) $data->attributes->get('id');
        $page = (int) $data->query->get('page', 1) ;
        $cars= $this->carRepository->findByDriverId($driverId, $page);

        return $cars;
    }
}

Driver repository :

<?php
# src/Repository/CarRepository.php
namespace App\Repository;

/**
 * @method Car|null find($id, $lockMode = null, $lockVersion = null)
 * @method Car|null findOneBy(array $criteria, array $orderBy = null)
 * @method Car[]    findAll()
 * @method Car[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class CarRepository extends ServiceEntityRepository
{
    private $itemsPerPage;

    public function __construct(RegistryInterface $registry, int $itemsPerPage)
    {
        parent::__construct($registry, Car::class);

        $this->itemsPerPage = $itemsPerPage;
    }

    /**
     * @param int $driverId
     * @param int $page
     * @return Paginator
     */
    public function findByDriverId(int $driverId, int $page = 1) : Paginator
    {
        $driver = $this->getEntityManager()->find(Driver::class, $driverId);
        $firstResult = ($page -1) * $this->itemsPerPage;

        $queryBuilder = $this->createQueryBuilder('Car')
            ->select('Car, RCD')
            ->innerJoin('Car.relationsCarDriver', 'RCD')
            ->where('RCD.driver= :driver')
            ->setParameter('driver', $driver);

        $query = $queryBuilder->getQuery()
            ->setFirstResult($firstResult)
            ->setMaxResults($this->itemsPerPage);

        // Set the paginator
        $doctrinePaginator = new DoctrinePaginator($query);
        $paginator = new Paginator($doctrinePaginator);

        // **Here we fullfill the "fake" OneToOne relation**
        foreach($doctrinePaginator->getIterator() as $car) {
            foreach($car->getRelationsCarDriver() as $relationCarDriver) {
                if($relationCarDriver->getCar() === $car && $relationCarDriver->getDriver() === $driver) {
                    // set the relationCarDriver  related to this Car & Driver
                    $car->setRelationCarDriver($relationCarDriver);
                }
            }
        }

        return $paginator;
    }
}

This way, when i request /drivers/1/cars i got this :

{
  "@context": "/api/contexts/Driver",
  "@id": "/api/drivers/1/cars",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/api/cars/1",
      "@type": "Car",
      "id": 1,
      "model": "Mustang",
      "relationCarDriver": {
          "@id": "/api/relations_car_driver/1",
          "@type": "RelationCarDriver",
          "skill": 63
      }
    },
    {
      "@id": "/api/cars/2",
      "@type": "Car",
      "id": 2,
      "model": "Chevrolet",
      "relationCarDriver": {
          "@id": "/api/relations_car_driver/2",
          "@type": "RelationCarDriver",
          "skill": 13
      }
    }
  ],
  "hydra:totalItems": 2
}

This might seems dirty, but this is the only way i found to achieve that and i think this respect REST guidelines ?

Please let me know if this you think there is something wrong about my method.