How to use inherited classes with API-Platform
Asked Answered
E

2

8

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?

Exhilaration answered 27/2, 2021 at 13:4 Comment(3)
In the top answer arguing for a single endpoint per resource the data exposed by both Cat and Dog resources are generalized into having a similar polymorphic Animal structure. Both being treated as Animal resources exposed via a single /animals endpoint. Your use case wants to distinguish between Animal subtype structures in the same /animals endpoint. A Cat is not a Dog though. Similar to function(Dog | Cat $animal) not being the same signature as funtion(Animal $animal).Shennashensi
@JeroenvanderLaan Agree. There were several comments on this response asking what I am asking, but there was no real resolution and it was obviously not api-platform specific. Is your response indicating that I should have separate \cat and \dog endpoints? ThanksExhilaration
I haven't tried configuring api platform to document an endpoint using the OAS v3 oneOf 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
A
13

I don't think a reputable source is available on this subject but i do have long experience with frameworks, abstract user interfaces and php and created MetaClass Tutorial Api Platform so i will try to answer your question myself.

The tutorial aims to cover the common ground of most CRUD and Search apps for both an api platform api and a react client generated with the api platform client generator. The tutorial does not cover inheritance and polymorphism because i do not think it occurs in many CRUD and Search apps but it adresses many aspects that do, for an overview see the list of chapters in the readme of the master branch. Api Platform offers a lot of generic functionality for the api of such apps out of the box that only needs to be configured for specific resources and operations. In the react branches this led to recurring patterns and refactoring into common components and eventually to an extended react client generator to accompany the tutorial. The scheme of serialization groups in this answer is a bit more generic because my understanding of the subject has improved over time.

Your classes worked out of the box on Api Platform 2.6 except for the repository classes that where not included. I removed them from the annotation as right now none of their specific methods seem to be called. You can allways add them again when your need them.

Against the common preference for REST in general to use a single endpoint /animals i chose for individual ones for /dogs, /cats, and /mice because:

  1. Api Platform identifies instances of resource classes by iri's that refer to these specific endpoints and inludes them as values of @id whenever these instances are serialized. The client generater, and i suppose the admin client too, depend on these endpoints to work for crud operations,
  2. With Api Platform specific post operations work out of the box with doctrine orm. An endpoint /animals would require a custom Denormalizer that can decide which concrete class to instantiate.
  3. With serialization groups specific end points give more control over serializations. Without that is it hard to get serialization compatible with the way it is done in chapter 4 of the tutorial,
  4. In many of the extension points of Api Platform it is easy to make things work for a spefic resource and all examples in the docs make use of that. Making them specific for the actual concrete subclass of the object at hand is undocumented and may not allways be possible.

I only include the /animals get collection operation because that allows the client to retrieve, search and sort a polymophic collection of animals in a single request.

In line with chapter 4 of the tutorial i removed the write annotation groups. Api Platforms deserialization already allows the client to only include those properties with post, put and patch that hold data and are meant be set, so the only purpose of deserialization groups can be to disallow certain properties to be set through (certain operations of) the api or to allow the creation of related objects through nested documents. When i tried to add a new cat by posting it as value of $ateByCat of a mouse i got error "Nested documents for attribute "ateByCat" are not allowed. Use IRIs instead." The same happened with adding one through Dog::$catsChased, so security by operation with certain roles granted does not seem to be compromised without write annotation groups. Seems like a sound default to me.

I added a ::getLabel method to Animal to represent each by a single string (annotated as http://schema.org/name). Basic CRUD and Search clients primarily show a single type of entities to the user and represent related entities this way. Having a specific schema.org/name property is more convenient for the client and making it a derived property is more flexible then then adding different properties depending on the type of entity. The label property is the only property that is added to the "related" group. This group is added to the normalization context of each type so that for the "get" operations of Cat, Doc and Mouse it is the only property serialized for related objects:

{
  "@context": "/contexts/Cat",
  "@id": "/cats/1",
  "@type": "Cat",
  "likesToPurr": true,
  "miceEaten": [
    {
      "@id": "/mice/3",
      "@type": "Mouse",
      "label": "2021-01-13"
    }
  ],
  "dogsChasedBy": [
    {
      "@id": "/dogs/2",
      "@type": "Dog",
      "label": "Bella"
    }
  ],
  "name": "Felix",
  "sex": "m",
  "weight": 12,
  "birthday": "2020-03-13T00:00:00+00:00",
  "color": "grey",
  "label": "Felix"
}

To get this result i had to make the serializaton groups of inherited properties specific to the concrete subclasses. To avoid repeating the definitions of those properties in each concrete subclass i addes some groups using yaml (added at the bottom of this answer). To make them work a added the following to api/config/packages/framework.yaml:

serializer:
    mapping:
        paths: ['%kernel.project_dir%/config/serialization']

The yaml configuratons blend in nicely with the annotations and only override those from the Animal class.

In line with chapter 4 of the tutorial i also added list groups for a more limited set of properties to be included in the result of get collection operations. When collections of entities are presented to the user the amount of information can soon become overkill and/or cloth up the screen, even with pagination. If the puprpose of the client(s) is clear to the api developer, making a selection in the api will speed up data transfer, especially if to-many relationships are left out. This results in serialization of a collection of mice like this:

{
  "@context": "/contexts/Mouse",
  "@id": "/mice",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/mice/3",
      "@type": "Mouse",
      "ateByCat": {
        "@id": "/cats/1",
        "@type": "Cat",
        "label": "Felix"
      },
      "label": "2021-01-13",
      "name": "mimi",
      "birthday": "2021-01-13T00:00:00+00:00",
      "color": "grey"
    }
  ],
  "hydra:totalItems": 1
}

The configuration of the serialization of get /animals is kind of a compromise. If i include the list groups of all subclasses:

 *     collectionOperations={
 *         "get"={
 *             "normalization_context"={"groups"={"cat:list", "dog:list", "mouse:list", "related"}}
 *         },
 *     },

i get a nice polymorhic response, but related objects also containing all properties of the list group of their types instead of only the label:

{
  "@context": "/contexts/Animal",
  "@id": "/animals",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/cats/1",
      "@type": "Cat",
      "likesToPurr": true,
      "name": "Felix",
      "birthday": "2020-03-13T00:00:00+00:00",
      "color": "grey",
      "label": "Felix"
    },
    {
      "@id": "/dogs/2",
      "@type": "Dog",
      "playsFetch": true,
      "name": "Bella",
      "birthday": "2019-03-13T00:00:00+00:00",
      "color": "brown",
      "label": "Bella"
    },
    {
      "@id": "/mice/3",
      "@type": "Mouse",
      "ateByCat": {
        "@id": "/cats/1",
        "@type": "Cat",
        "likesToPurr": true,
        "name": "Felix",
        "birthday": "2020-03-13T00:00:00+00:00",
        "color": "grey",
        "label": "Felix"
      },
      "label": "2021-01-13",
      "name": "mimi",
      "birthday": "2021-01-13T00:00:00+00:00",
      "color": "grey"
    }
  ],
  "hydra:totalItems": 3
}

This is nice for the example at hand, but with more relationships it can get a litte large so for a generic compromise i only include "animal:list" and "referred", resulting in a smaller response:

{
  "@context": "/contexts/Animal",
  "@id": "/animals",
  "@type": "hydra:Collection",
  "hydra:member": [
    {
      "@id": "/cats/1",
      "@type": "Cat",
      "name": "Felix",
      "color": "grey",
      "label": "Felix"
    },
    {
      "@id": "/dogs/2",
      "@type": "Dog",
      "name": "Bella",
      "color": "brown",
      "label": "Bella"
    },
    {
      "@id": "/mice/3",
      "@type": "Mouse",
      "ateByCat": {
        "@id": "/cats/1",
        "@type": "Cat",
        "name": "Felix",
        "color": "grey",
        "label": "Felix"
      },
      "label": "2021-01-13",
      "name": "mimi",
      "color": "grey"
    }
  ],
  "hydra:totalItems": 3
}

As you can see polymorhism is still possible (ateByCat) and the problem does get smaller but it does not disappear. The problem can not be solved with serialization groups because seen from the serialization context the relationship Cat eats Mouse is recursive. A better solution could be to decorate api_platform.serializer.context_builder to add a custom callback for the properties of the to-one recursive relationships, but the problem of serializing recursive relations is not specific to inheritance and therefore out of the scope of this question so for now i do not elaborate on this solution.

Api Platform 2.6 does not support oneOf, allOf, or anyOf. Instead it produces quite a long list of types resulting from each combination of serialazation groups used, each with all included properties in a flat list. The resulting json IMHO is too large to include here so i only include the list of type names:

Animal-animal.list_related
Animal.jsonld-animal.list_related
Cat
Cat-cat.list_related
Cat-cat.read_cat.list_related
Cat-dog.read_dog.list_related
Cat-mouse.list_related
Cat-mouse.read_mouse.list_related
Cat.jsonld
Cat.jsonld-cat.list_related
Cat.jsonld-cat.read_cat.list_related
Cat.jsonld-dog.read_dog.list_related
Cat.jsonld-mouse.list_related
Cat.jsonld-mouse.read_mouse.list_related
Dog
Dog-cat.read_cat.list_related
Dog-dog.list_related
Dog-dog.read_dog.list_related
Dog.jsonld
Dog.jsonld-cat.read_cat.list_related
Dog.jsonld-dog.list_related
Dog.jsonld-dog.read_dog.list_related
Greeting
Greeting.jsonld
Mouse
Mouse-cat.read_cat.list_related
Mouse-mouse.list_related
Mouse-mouse.read_mouse.list_related
Mouse.jsonld
Mouse.jsonld-cat.read_cat.list_related
Mouse.jsonld-mouse.list_related
Mouse.jsonld-mouse.read_mouse.list_related 

If you paste the code below in corresponding files in the api platform standard edition and make the described configuration you should be able to retrieve the entire openapi scheme from https://localhost/docs.json

Code

<?php
// api/src/Entity/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 Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ApiResource(
 *     collectionOperations={
 *         "get"={
 *             "normalization_context"={"groups"={"animal:list", "related"}}
 *         },
 *     },
 *     itemOperations={},
 * )
 * @ORM\InheritanceType("JOINED")
 * @ORM\DiscriminatorColumn(name="type", type="string", length=32)
 * @ORM\DiscriminatorMap({"dog" = "Dog", "cat" = "Cat", "mouse" = "Mouse"})
 * @ORM\Entity()
 */
abstract class Animal
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     * @Groups({"animal:list"})
     */
    private $name;

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

    /**
     * @ORM\Column(type="integer")
     * @ApiProperty(
     *     attributes={
     *         "openapi_context"={
     *             "example"=1000
     *         }
     *     }
     * )
     */
    private $weight;

    /**
     * @ORM\Column(type="date")
     * @ApiProperty(
     *     attributes={
     *         "openapi_context"={
     *             "example"="2020/1/1"
     *         }
     *     }
     * )
     */
    private $birthday;

    /**
     * @ORM\Column(type="string", length=255)
     * @Groups({"animal:list"})
     */
    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;
    }

    /**
     * Represent the entity to the user in a single string
     * @return string
     * @ApiProperty(iri="http://schema.org/name")
     * @Groups({"related"})
     */
    function getLabel() {
        return $this->getName();
    }

}

<?php
// api/src/Entity/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 Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ApiResource(
 *     collectionOperations={
 *         "get"={
 *             "normalization_context"={"groups"={"cat:list", "related"}}
 *         },
 *         "post"
 *     },
 *     itemOperations={"get", "put", "patch", "delete"},
 *     normalizationContext={"groups"={"cat:read", "cat:list", "related"}}
 * )
 * @ORM\Entity()
 */
class Cat extends Animal
{
    /**
     * @ORM\Column(type="boolean")
     * @Groups({"cat:list"})
     */
    private $likesToPurr;

    /**
     * #@ApiSubresource()
     * @ORM\OneToMany(targetEntity=Mouse::class, mappedBy="ateByCat")
     * @MaxDepth(2)
     * @Groups({"cat:read"})
     */
    private $miceEaten;

    /**
     * #@ApiSubresource()
     * @ORM\ManyToMany(targetEntity=Dog::class, inversedBy="catsChased")
     * @MaxDepth(2)
     * @Groups({"cat:read"})
     */
    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;
    }
}

<?php
// api/src/Entity/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 Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ApiResource(
 *     collectionOperations={
 *         "get"={
 *             "normalization_context"={"groups"={"dog:list", "related"}}
 *         },
 *         "post"
 *     },
 *     itemOperations={"get", "put", "patch", "delete"},
 *     normalizationContext={"groups"={"dog:read", "dog:list", "related"}},
 * )
 * @ORM\Entity()
 */
class Dog extends Animal
{
    /**
     * @ORM\Column(type="boolean")
     * @Groups({"dog:list"})
     */
    private $playsFetch;

    /**
     * @ORM\Column(type="string", length=255)
     * @Groups({"dog:read"})
     * @ApiProperty(
     *     attributes={
     *         "openapi_context"={
     *             "example"="red"
     *         }
     *     }
     * )
     */
    private $doghouseColor;

    /**
     * #@ApiSubresource()
     * @ORM\ManyToMany(targetEntity=Cat::class, mappedBy="dogsChasedBy")
     * @MaxDepth(2)
     * @Groups({"dog:read"})
     */
    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;
    }
}

<?php
// api/src/Entity/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 Doctrine\ORM\Mapping as ORM;

/**
 * @ApiResource(
 *     collectionOperations={
 *         "get"={
 *             "normalization_context"={"groups"={"mouse:list", "related"}}
 *         },
 *         "post"
 *     },
 *     itemOperations={"get", "put", "patch", "delete"},
 *     normalizationContext={"groups"={"mouse:read", "mouse:list", "related"}},
 * )
 * @ORM\Entity()
 */
class Mouse extends Animal
{
    /**
     * @ORM\Column(type="boolean")
     * @Groups({"mouse:read"})
     */
    private $likesCheese;

    /**
     * #@ApiSubresource()
     * @ORM\ManyToOne(targetEntity=Cat::class, inversedBy="miceEaten")
     * @MaxDepth(2)
     * @Groups({"mouse:list", "animal:list"})
     */
    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;
    }

    /**
     * Represent the entity to the user in a single string
     * @return string
     * @ApiProperty(iri="http://schema.org/name")
     * @Groups({"related"})
     */
    function getLabel() {
        return $this->getBirthday()->format('Y-m-d');
    }
}

# api/config/serialization/Cat.yaml
App\Entity\Cat:
    attributes:
        name:
            groups: ['cat:list']
        sex:
            groups: ['cat:read']
        weight:
            groups: ['cat:read']
        birthday:
            groups: ['cat:list']
        color:
            groups: ['cat:list']

# api/config/serialization/Dog.yaml
App\Entity\Dog:
    attributes:
        name:
            groups: ['dog:list']
        sex:
            groups: ['dog:read']
        weight:
            groups: ['dog:read']
        birthday:
            groups: ['dog:list']
        color:
            groups: ['dog:list']

# api/config/serialization/Mouse.yaml
App\Entity\Mouse:
    attributes:
        name:
            groups: ['mouse:list']
        sex:
            groups: ['mouse:read']
        weight:
            groups: ['mouse:read']
        birthday:
            groups: ['mouse:list']
        color:
            groups: ['mouse:list']

In reaction to the supplementary information

With respect to the use of the label see chapter 4 of the tutorial (readmes of both branches). The method ::getLabel also brings encapsulation: The representation may be modified without changing the api.

With respect to oneOf, allOf, or anyOf: the long list of types Apip generates is ugly, but i guess it will be work for clients that want to automatically validate property values and abstract user interfaces like the admin client. For designing/scaffolding a client and for customizing an abstract user interface they might be troublesome, so it would be nice if Api Platform would automatically use them appropriately, but for most development teams i don't think an investment into improving the OpenApi docs factory will be earned back. In other words, adapting the clients manually will usually be less work. So for now i do not spend any time on this.

More problematic is that in the JsonLD docs properties of the types from operations specified with "output"= get merged into the type of the resource itself. But this is not related to inheritance.

Anopheles answered 15/3, 2021 at 16:7 Comment(2)
Thanks MetaClass. I will read your answer several times to ensure I have a good understanding, and afterwards hope you are willing to answer a couple questions. Over the past few days, I learned that if Animal's properties are protected instead of private, things get much better! Also, added my repositories to my original post so you could see how I got specific endpoints to only return the specific class (previously, /cats returned all animals). Ideally, if valuable content is identified, you could add it to your answer so that it is even more comprehensive.Exhilaration
Hello MetaClass, I added some content related to your answer to the end of my original question, and am hoping you can take a look and comment. Also, what are the "see the list op chapters in the readme of the master branch"? Thanks!Exhilaration
W
0

I had a similar issue, while using a DTO extending an abstract DTO. The attribute/annotation ApiResource was set on the abstract DTO and thus all extra public properties in the concrete DTO was missing.

To solve the problem I've made a custom Normalizer. It is explained here https://api-platform.com/docs/core/serialization/#the-serialization-process in the section "Changing the Serialization Context on a Per-item Basis". The normalizer's goal is to get the missing properties by calling a dedicated DTO method, in my case it is calling JsonSerializable::jsonSerialize().

Here is the code :

readonly abstract class AbstractDTO implements JsonSerializable
{
    public function __construct(public string $foo)
    {
    }

    public function jsonSerialize(): mixed
    {
        return (array) $this;
    }
}

readonly class ConcreteDTO1 extends AbstractDTO
{
    public function __construct(string $foo, public string $bar)
    {
        parent::__construct($foo);
        
    }
}

readonly class ConcreteDTO2 extends AbstractDTO
{
    public function __construct(string $foo, public string $bazzz)
    {
        parent::__construct($foo);
    }
}

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;


class DtoAttributeNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private const ALREADY_CALLED = 'DTO_ATTRIBUTE_NORMALIZER_ALREADY_CALLED';


    public function normalize($object, $format = null, array $context = [])
    {
        $context[self::ALREADY_CALLED] = true;
        //$data contains api-platform attributes like "@id"
        $data = $this->normalizer->normalize($object, $format, $context);

        return array_merge($object->jsonSerialize(), $data);
    }


    public function supportsNormalization($data, $format = null, array $context = []): bool
    {
        // Make sure we're not called twice
        if (isset($context[self::ALREADY_CALLED])) {
            return false;
        }

        return $data instanceof AbstractDTO;
    }
}
Wrangler answered 6/2 at 14:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.