I wish to use API-Platform to perform CRUD operations on object hierarchy classes. I found little written when using inherited classes with API-Platform and some but not much more when used with Symfony's serializer, and am looking for better direction on what needs to be implemented differently specifically for inherited classes.
Let's say I have Dog, Cat, and Mouse inherited from Animal where Animal is abstract (see below). These entities have been created using bin/console make:entity
, and have only been modified to extend the parent class (as well as their respective repositories) and to have Api-Platform annotation added.
How should groups be used with inherited classes? Should each of the child classes (i.e. Dog, Cat, Mouse) have their own group or should just the parent animal
group be used? When using the animal
group for all, some routes respond with The total number of joined relations has exceeded the specified maximum. ...
, and when mixed, sometimes get Association name expected, 'miceEaten' is not an association.
. Will these groups also allow ApiPropertys on the parent apply to the child entities (i.e. Animal::weight has a default openapi_context example value of 1000)?
API-Platform does not discuss CTI or STI and the only relevant reference I found in the documentation was regarding MappedSuperclass. Need a MappedSuperclass be used in addition to CLI or STI? Note that I tried applying MappedSuperclass
to Animal
, but received an error as expected.
Based on this post as well as others, it appears that the preferred RESTful implementation is to use a single endpoint /animals
instead of individual /dogs
, /cats
, and /mice
. Agree? How could this be implemented with API-Platform? If the @ApiResource()
annotation is applied only to Animal, I get this single desired URL but don't get the child properties for Dog, Cat, and Mouse in the OpenAPI Swagger documentation nor the actual request. If the @ApiResource()
annotation is applied only to Dog, Cat, and Mouse, then there is no way to get a combined collection of all animals and I have multiple endpoints. Need it be applied to all three? It appears that OpenApi's key words oneOf
, allOf
, and anyOf
might provide a solution as described by this stackoverflow answer as well as this Open-Api specification. Does Api-Platform support this and if so how?
Animal
<?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 App\Repository\AnimalRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "patch", "delete"},
* normalizationContext={"groups"={"animal:read", "dog:read", "cat:read", "mouse:read"}},
* denormalizationContext={"groups"={"animal:write", "dog:write", "cat:write", "mouse:write"}}
* )
* @ORM\InheritanceType("JOINED")
* @ORM\DiscriminatorColumn(name="type", type="string", length=32)
* @ORM\DiscriminatorMap({"dog" = "Dog", "cat" = "Cat", "mouse" = "Mouse"})
* @ORM\Entity(repositoryClass=AnimalRepository::class)
*/
abstract class Animal
{
/**
* @Groups({"animal:read"})
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @ORM\Column(type="integer")
*/
private $id;
/**
* @Groups({"animal:read", "animal:write"})
* @ORM\Column(type="string", length=255)
*/
private $name;
/**
* @Groups({"animal:read", "animal:write"})
* @ORM\Column(type="string", length=255)
*/
private $sex;
/**
* @Groups({"animal:read", "animal:write"})
* @ORM\Column(type="integer")
* @ApiProperty(
* attributes={
* "openapi_context"={
* "example"=1000
* }
* }
* )
*/
private $weight;
/**
* @Groups({"animal:read", "animal:write"})
* @ORM\Column(type="date")
* @ApiProperty(
* attributes={
* "openapi_context"={
* "example"="2020/1/1"
* }
* }
* )
*/
private $birthday;
/**
* @Groups({"animal:read", "animal:write"})
* @ORM\Column(type="string", length=255)
*/
private $color;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSex(): ?string
{
return $this->sex;
}
public function setSex(string $sex): self
{
$this->sex = $sex;
return $this;
}
public function getWeight(): ?int
{
return $this->weight;
}
public function setWeight(int $weight): self
{
$this->weight = $weight;
return $this;
}
public function getBirthday(): ?\DateTimeInterface
{
return $this->birthday;
}
public function setBirthday(\DateTimeInterface $birthday): self
{
$this->birthday = $birthday;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): self
{
$this->color = $color;
return $this;
}
}
Dog
<?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 Symfony\Component\Serializer\Annotation\MaxDepth;
use App\Repository\DogRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "patch", "delete"},
* normalizationContext={"groups"={"dog:read"}},
* denormalizationContext={"groups"={"dog:write"}}
* )
* @ORM\Entity(repositoryClass=DogRepository::class)
*/
class Dog extends Animal
{
/**
* @ORM\Column(type="boolean")
* @Groups({"dog:read", "dog:write"})
*/
private $playsFetch;
/**
* @ORM\Column(type="string", length=255)
* @Groups({"dog:read", "dog:write"})
* @ApiProperty(
* attributes={
* "openapi_context"={
* "example"="red"
* }
* }
* )
*/
private $doghouseColor;
/**
* #@ApiSubresource()
* @ORM\ManyToMany(targetEntity=Cat::class, mappedBy="dogsChasedBy")
* @MaxDepth(2)
* @Groups({"dog:read", "dog:write"})
*/
private $catsChased;
public function __construct()
{
$this->catsChased = new ArrayCollection();
}
public function getPlaysFetch(): ?bool
{
return $this->playsFetch;
}
public function setPlaysFetch(bool $playsFetch): self
{
$this->playsFetch = $playsFetch;
return $this;
}
public function getDoghouseColor(): ?string
{
return $this->doghouseColor;
}
public function setDoghouseColor(string $doghouseColor): self
{
$this->doghouseColor = $doghouseColor;
return $this;
}
/**
* @return Collection|Cat[]
*/
public function getCatsChased(): Collection
{
return $this->catsChased;
}
public function addCatsChased(Cat $catsChased): self
{
if (!$this->catsChased->contains($catsChased)) {
$this->catsChased[] = $catsChased;
$catsChased->addDogsChasedBy($this);
}
return $this;
}
public function removeCatsChased(Cat $catsChased): self
{
if ($this->catsChased->removeElement($catsChased)) {
$catsChased->removeDogsChasedBy($this);
}
return $this;
}
}
Cat
<?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 Symfony\Component\Serializer\Annotation\MaxDepth;
use App\Repository\CatRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "patch", "delete"},
* normalizationContext={"groups"={"cat:read"}},
* denormalizationContext={"groups"={"cat:write"}}
* )
* @ORM\Entity(repositoryClass=CatRepository::class)
*/
class Cat extends Animal
{
/**
* @ORM\Column(type="boolean")
* @Groups({"cat:read", "cat:write"})
*/
private $likesToPurr;
/**
* #@ApiSubresource()
* @ORM\OneToMany(targetEntity=Mouse::class, mappedBy="ateByCat")
* @MaxDepth(2)
* @Groups({"cat:read", "cat:write"})
*/
private $miceEaten;
/**
* #@ApiSubresource()
* @ORM\ManyToMany(targetEntity=Dog::class, inversedBy="catsChased")
* @MaxDepth(2)
* @Groups({"cat:read", "cat:write"})
*/
private $dogsChasedBy;
public function __construct()
{
$this->miceEaten = new ArrayCollection();
$this->dogsChasedBy = new ArrayCollection();
}
public function getLikesToPurr(): ?bool
{
return $this->likesToPurr;
}
public function setLikesToPurr(bool $likesToPurr): self
{
$this->likesToPurr = $likesToPurr;
return $this;
}
/**
* @return Collection|Mouse[]
*/
public function getMiceEaten(): Collection
{
return $this->miceEaten;
}
public function addMiceEaten(Mouse $miceEaten): self
{
if (!$this->miceEaten->contains($miceEaten)) {
$this->miceEaten[] = $miceEaten;
$miceEaten->setAteByCat($this);
}
return $this;
}
public function removeMiceEaten(Mouse $miceEaten): self
{
if ($this->miceEaten->removeElement($miceEaten)) {
// set the owning side to null (unless already changed)
if ($miceEaten->getAteByCat() === $this) {
$miceEaten->setAteByCat(null);
}
}
return $this;
}
/**
* @return Collection|Dog[]
*/
public function getDogsChasedBy(): Collection
{
return $this->dogsChasedBy;
}
public function addDogsChasedBy(Dog $dogsChasedBy): self
{
if (!$this->dogsChasedBy->contains($dogsChasedBy)) {
$this->dogsChasedBy[] = $dogsChasedBy;
}
return $this;
}
public function removeDogsChasedBy(Dog $dogsChasedBy): self
{
$this->dogsChasedBy->removeElement($dogsChasedBy);
return $this;
}
}
Mouse
<?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 Symfony\Component\Serializer\Annotation\MaxDepth;
use App\Repository\MouseRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "patch", "delete"},
* normalizationContext={"groups"={"mouse:read"}},
* denormalizationContext={"groups"={"mouse:write"}}
* )
* @ORM\Entity(repositoryClass=MouseRepository::class)
*/
class Mouse extends Animal
{
/**
* @ORM\Column(type="boolean")
* @Groups({"mouse:read", "mouse:write"})
*/
private $likesCheese;
/**
* #@ApiSubresource()
* @ORM\ManyToOne(targetEntity=Cat::class, inversedBy="miceEaten")
* @MaxDepth(2)
* @Groups({"mouse:read", "mouse:write"})
*/
private $ateByCat;
public function getLikesCheese(): ?bool
{
return $this->likesCheese;
}
public function setLikesCheese(bool $likesCheese): self
{
$this->likesCheese = $likesCheese;
return $this;
}
public function getAteByCat(): ?Cat
{
return $this->ateByCat;
}
public function setAteByCat(?Cat $ateByCat): self
{
$this->ateByCat = $ateByCat;
return $this;
}
}
Supplementary information for MetaClass's answer
Below is my approach to repositories and the key takeaway is the most specific class sets the entity in the constructor.
class AnimalRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry, ?string $class=null)
{
parent::__construct($registry, $class??Animal::class);
}
}
class DogRepository extends AnimalRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Dog::class);
}
}
// Cat and Mouse Repository similar
I would have liked to follow "the common preference for REST in general to use a single endpoint /animals", but understand your rational "for choosing individual ones for /dogs, /cats, and /mice". To overcome your reasons, I also considered making Animal concrete and using composition for polymorphism so that Animal would have some sort of animal type object. I suppose eventually Doctrine inheritance would still be needed to allow Animal to have a one-to-one relationship with this object, but the only properties would be the PK ID and discriminator. I will likely give up this pursuit.
Not sure whether I agree or not with your approach of not using denormalizationContext, but will take your approach unless circumstances change and I need more flexibility.
I do not understand your use of the label. At first I thought it was some unique identifier or maybe some means to expose the discriminator, but don't think the case. Please elaborate.
Regarding "To avoid repeating the definitions of those properties in each concrete subclass i addes some groups using yaml", my approach was to make properites for the abstract Animal class protected instead of private so that PHP can use reflection, and used groups "animal:read" in abstract Animal and groups "mouse:read", etc in the individual concrete classes, and got the results I desired.
Yes, see your point about limiting results for a list versus a detail.
I originally thought that @MaxDepth
would solve the recursive issues, but couldn't get it working. What did work, however, was using @ApiProperty(readableLink=false)
.
I found some cases where the API-Platform generated swagger specification displayed anyOf
in SwaggerUI, but agree API-Platform does not seem to really support oneOf, allOf, or anyOf. Somehow, however, will implementing this be needed? For instance, animal ID was in some other table, documentation would need to oneOf Cat, Dog, or Mouse, no? Or is this long list of types resulting from each combination of serialization groups used instead?
Cat
andDog
resources are generalized into having a similar polymorphicAnimal
structure. Both being treated asAnimal
resources exposed via a single/animals
endpoint. Your use case wants to distinguish betweenAnimal
subtype structures in the same/animals
endpoint. ACat
is not aDog
though. Similar tofunction(Dog | Cat $animal)
not being the same signature asfuntion(Animal $animal)
. – Shennashensi\cat
and\dog
endpoints? Thanks – ExhilarationoneOf
feature. This might be what you're looking for if you can get it to work. Though, I do lean towards having separate endpoints for resources that do not share the same structure. – Shennashensi