Search code examples
phprestsymfonydoctrine-ormapi-platform.com

Multiple key identifiers for API-Platform resources


I have a Chart object which contains a collection of Serie objects and these Serie objects contain collections of Data objects. Instead of requiring a REST API to identify the Serie and Data objects by surrogate primary keys, I wish to do so based on their position within their respective collection.

The database schema is as follows. Originally I considered making serie's chart_id/position and data's chart_id/serie_id/position composite primary keys, however, Doctrine can only do so for one level (i.e. Serie) and to be consistent am using a surrogate keys for all tables.

chart
- id (PK autoincrement)
- name

serie
- id (PK autoincrement)
- position (int with unique constraint with chart_id)
- name
- chart_id (FK)

data
- id (PK autoincrement)
- position (int with unique constraint with serie_id)
- name
- serie_id (FK)
- value

A fully hydrated response for /charts/1 will return the JSON shown below. For instance, to locate the data object whose name is Series1Data1, the url would be /charts/1/series/0/datas/1, or if necessary /datas/chart=1;seriePosition=0;dataPosition=1 would also work.

{
    "id": 1,
    "name": "chart1"
    "series": [{
            "chart": "/chart/1",
            "position": 0,
            "name": "series0.chart1",
            "datas": [{
                    "serie": "/charts/1/series/0",
                    "position": 0,
                    "name": "datas0.series0.chart1"
                }, {
                    "serie": "/series/chart=1;position=0",
                    "position": 1,
                    "name": "datas1.series0.chart1"
                }
            ]
        }, {
            "chart": "/chart/1",
            "position": 1,
            "name": "series1.chart1",
            "datas": []
        }
    ]
}

To change the identifiers, I used @ApiProperty to set identifier for Serie's and Data's primary key $id to false and for Serie's $chart and $position and Data's $serie and $position to true.

Entity/Chart.php

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
//use ApiPlatform\Core\Annotation\ApiProperty;
//use ApiPlatform\Core\Annotation\ApiSubresource;
//use Symfony\Component\Serializer\Annotation\Groups;
//use Symfony\Component\Serializer\Annotation\SerializedName;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

/**
 * @ORM\Entity()
 * @ApiResource()
 */
class Chart
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\OneToMany(targetEntity=Serie::class, mappedBy="chart")
     */
    private $series;

    /**
     * @ORM\Column(type="string", length=45)
     */
    private $name;
    
    public function __construct()
    {
        $this->series = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getSeries(): Collection
    {
        return $this->series;
    }

    public function addSeries(Serie $series): self
    {
        exit(sprintf('Chart::addSeries() $this->series->contains($series): %s', $this->series->contains($series)?'true':'false'));
        if (!$this->series->contains($series)) {
            $this->series[] = $series;
            $series->setChart($this);
        }

        return $this;
    }

    public function removeSeries(Serie $series): self
    {
        if ($this->series->removeElement($series)) {
            if ($series->getChart() === $this) {
                $series->setChart(null);
            }
        }

        return $this;
    }

    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

     public function getName()
    {
        return $this->name;
    }
}

Entity/Serie.php

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiProperty;
//use ApiPlatform\Core\Annotation\ApiSubresource;
//use Symfony\Component\Serializer\Annotation\Groups;
//use Symfony\Component\Serializer\Annotation\SerializedName;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

/**
 * @ORM\Entity()
 * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="unique_position_serie", columns={"chart_id", "position"}), @ORM\UniqueConstraint(name="unique_name_serie", columns={"chart_id", "name"})})
 * @ApiResource(
 *   collectionOperations={
 *     "get1" = {  
 *       "method" = "get",
 *     },
 *     "get2" = {  
 *       "method" = "get",
 *       "path" = "/charts/{id}/series",
 *       "requirements" = {
 *         "id" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "id",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Chart ID",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     },
 *     "post1" = {  
 *       "method" = "post",
 *     },
 *     "post2" = {  
 *       "method" = "post",
 *       "path" = "/charts/{id}/series",
 *       "requirements" = {
 *         "id" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "id",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Chart ID",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     }
 *   },
 *   itemOperations={
 *     "get1" = {  
 *       "method" = "get",
 *     },
 *     "get2" = {  
 *       "method" = "get",
 *       "path" = "/charts/{id}/series/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     },
 *     "put1" = {  
 *       "method" = "put",
 *     },
 *     "put2" = {  
 *       "method" = "put",
 *       "path" = "/charts/{id}/series/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     },
 *     "patch1" = {  
 *       "method" = "patch",
 *     },
 *     "patch2" = {  
 *       "method" = "patch",
 *       "path" = "/charts/{id}/series/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     },
 *     "delete1" = {  
 *       "method" = "delete",
 *     },
 *     "delete2" = {  
 *       "method" = "delete",
 *       "path" = "/charts/{id}/series/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     }
 *   }
 * )
 */

class Serie
{
    /**
    * @ORM\Id
    * @ORM\GeneratedValue(strategy="IDENTITY")
    * @ORM\Column(type="integer")
    * @ApiProperty(identifier=false)
    */
    private $id;

    /**
    * @ORM\ManyToOne(targetEntity=Chart::class, inversedBy="series")
    * @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
    * @ApiProperty(identifier=true)
    * ApiProperty(push=true)
    */
    private $chart;

    /**
    * @ORM\Column(type="integer")
    * @ApiProperty(identifier=true)
    * ApiProperty(push=true)
    */
    private $position;

    /**
    * @ORM\OneToMany(targetEntity=Data::class, mappedBy="serie")
    */
    private $data;

    /**
    * @ORM\Column(type="string", length=45)
    */
    private $name;

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

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getChart(): ?Chart
    {
        return $this->chart;
    }

    public function setChart(?Chart $chart): self
    {
        $this->chart = $chart;

        return $this;
    }

    public function getData(): Collection
    {
        return $this->data;
    }

    public function addData(Data $data): self
    {
        if (!$this->data->contains($data)) {
            $this->data[] = $data;
            $data->setSerie($this);
        }

        return $this;
    }

    public function removeData(Data $data): self
    {
        if ($this->data->removeElement($data)) {
            if ($data->getSerie() === $this) {
                $data->setSerie(null);
            }
        }

        return $this;
    }

    public function setPosition(int $position)
    {
        $this->position = $position;

        return $this;
    }

    public function getPosition()
    {
        return $this->position;
    }

    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    public function getName()
    {
        return $this->name;
    }
}

Entity/Data.php

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiProperty;
//use ApiPlatform\Core\Annotation\ApiSubresource;
//use Symfony\Component\Serializer\Annotation\Groups;
//use Symfony\Component\Serializer\Annotation\SerializedName;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="unique_name_data", columns={"serie_id", "name"}), @ORM\UniqueConstraint(name="unique_position_data", columns={"serie_id", "position"})})
 * @ApiResource(
 *   collectionOperations={
 *     "get1" = {  
 *       "method" = "get",
 *     },
 *     "get2" = {  
 *       "method" = "get",
 *       "path" = "/charts/{id}/series/{position}/datas",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "id",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Chart ID",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           },
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     },
 *     "post1" = {  
 *       "method" = "post",
 *     },
 *     "post2" = {  
 *       "method" = "post",
 *       "path" = "/charts/{id}/series/{position}/datas",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "id",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Chart ID",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           },
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *         }
 *       }
 *     }
 *   },
 *   itemOperations={
 *     "get1" = {  
 *       "method" = "get",
 *     },
 *     "get2" = {  
 *       "method" = "get",
 *       "path" = "/charts/{id}/series/{series_position}/datas/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "series_position" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "series_position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           },
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Datas position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *           }
 *       }
 *     },
 *     "put1" = {  
 *       "method" = "put",
 *     },
 *     "put2" = {  
 *       "method" = "put",
 *       "path" = "/charts/{id}/series/{series_position}/datas/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "series_position" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "series_position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           },
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Datas position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *           }
 *       }
 *     },
 *     "patch1" = {  
 *       "method" = "patch",
 *     },
 *     "patch2" = {  
 *       "method" = "patch",
 *       "path" = "/charts/{id}/series/{series_position}/datas/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "series_position" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "series_position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           },
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Datas position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *           }
 *       }
 *     },
 *     "delete1" = {  
 *       "method" = "delete",
 *     },
 *     "delete2" = {  
 *       "method" = "delete",
 *       "path" = "/charts/{id}/series/{series_position}/datas/{position}",
 *       "requirements" = {
 *         "id" = "\d+",
 *         "series_position" = "\d+",
 *         "position" = "\d+",
 *       },
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "series_position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Series position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           },
 *           {
 *             "name" = "position",
 *             "in" = "path",
 *             "required" = true,
 *             "description" = "Datas position in chart",
 *             "schema" = {
 *               "type" = "integer"
 *             }
 *           }
 *           }
 *       }
 *     }
 *   }
 * )
 */
class Data
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     * @ORM\Column(type="integer")
     * @ApiProperty(identifier=false)
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity=Serie::class, inversedBy="data")
     * @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
     * @ApiProperty(identifier=true)
     * ApiProperty(push=true)
     */
    private $serie;

    /**
     * @ORM\Column(type="integer")
     * @ApiProperty(identifier=true)
     * ApiProperty(push=true)
     */
    private $position;

    /**
     * @ORM\Column(type="string", length=45)
     */
    private $name;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getSerie(): ?Serie
    {
        return $this->serie;
    }

    public function setSerie(?Serie $serie): self
    {
        $this->serie = $serie;

        return $this;
    }

    public function setPosition(int $position)
    {
        $this->position = $position;

        return $this;
    }

    public function getPosition()
    {
        return $this->position;
    }

    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    public function getName()
    {
        return $this->name;
    }
}

API-Platform is able to generate an IRI for the Chart and Serie item, but not for the Data item. I suspect this occurs because Data's $serie identifier property requires both the chartId and its position, but don't know how to resolve it.

I looked into subresources, however, they only support GET requests and subresources will be deprecated in favor of multiple ApiResources (however, I don't even know what "multiple ApiResources" means). Also, maybe related to decorating the IriConverter, but not sure.

What do I need to do to allow the Data resource to be identified by its position in the serie collection, and for the Swagger documentation to reflect doing so?

EDIT - ADDITIONAL CONTENT

I changed the entities in an attempt to implement two different approaches, but unfortunately neither fully work. Should I focus my energies solely on one of the two approaches?

  1. Where the identifier of the parent is in the query (i.e. /datas/chart=1;serie_position=0;data_position=0)

  2. Where the identifier of the parent is in the path(i.e. /charts/1/series/0/datas/0)

If I wanted to use placeholder chart_id instead of id (i.e. /charts/{chart_id}/series/{series_position}/datas/{position} instead of /charts/{id}/series/{series_position}/datas/{position}, how can I remove or rename chart's id?

Should I be doing this differently so I don't need all the annotations and maybe rename chart's id? To some degree I was able to get Swagger to provide the fields, but don't think I am doing it right. Maybe by decorating api_platform.openapi.factory?

enter image description here


Solution

  • Api Platform can deal with such situations in different ways.

    One would be to create your own state provider as explained in v3 documentation

    In your Data class use:

    #[ApiResource(
       operations: [
          new Get(
             uriTemplate: 'charts/{id}/series/{series_id}/data/{position}'),
             provider: YourCustomeStateProvider::class
          ...
       ]
    )]
    

    If you want to rename the id placeholder by chart_id you can :

    • rename your $id variable by $chartId and put the #[ApiProperty(identifier: true)] attribute on it
    • or use the #[SerializedName("chart_id")] attribute $id

    Then in your state provider, you retrieve your variables through $uriVariables['chart_id'], $uriVariables['series_id'] and $uriVariables['position'] and use them with Doctrine (repo / querybuilder /etc.) to retrieve the desired data.

    Note: pagination and filters will need some customization too (ie. see pagination for custom state provider)

    For write endpoints, you'll have to create your own state processor.

    You could also use subresources as described in v3. But keep in mind that it won't just work by magic: updating subresources is a very subjective topic and Api Platform default provider/processor won't always cover your need in this case. That's why the documentation advise for custom provider/processor.