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?
Where the identifier of the parent is in the query (i.e.
/datas/chart=1;serie_position=0;data_position=0)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?
