api-platform / core

The server component of API Platform: hypermedia and GraphQL APIs in minutes
https://api-platform.com
MIT License
2.39k stars 848 forks source link

Operation level serialization groups disregarded in parent classes when resource level serialization is defined #2967

Open tec4 opened 4 years ago

tec4 commented 4 years ago

I've noticed the documentation indicates that when serialization groups are specified at an operation level they should take precedence over the configuration specified at the resource level. This makes sense and seems to work fine when your entity does not extend from another class.

Example of what works: Notice that I have resource level normalizationContext and denormalizationContext defined on the CheeseListing entity. But, I also have the operation POST set up to use the serialization group of "create" and have applied that group to the $description property. This works as expected and the Swagger docs generate everything correctly (model section has a "cheeses-create" definition and the $description property is defined).

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
use Symfony\Component\Validator\Constraints as Assert;
use Carbon\Carbon;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;

/**
 * @ApiResource(
 *      collectionOperations={
 *          "get",
 *          "post"={
 *              "normalization_context"={"groups"={"create"}},
 *              "denormalization_context"={"groups"={"create"}},
 *          }
 *      },
 *      itemOperations={
 *          "get",
 *          "put"
 *      },
 *     normalizationContext={"groups"={"cheese_listing:read"}, "swagger_definition_name"="Read"},
 *     denormalizationContext={"groups"={"cheese_listing:write"}, "swagger_definition_name"="Write"},
 *     shortName="cheeses",
 *     attributes={
 *          "pagination_items_per_page"=2,
 *          "formats"={"jsonld", "json", "html", "jsonhal", "csv"={"text/csv"}}
 *     }
 * )
 * @ApiFilter(BooleanFilter::class, properties={"isPublished"})
 * @ApiFilter(SearchFilter::class, properties={"title": "partial", "description": "partial"})
 * @ApiFilter(RangeFilter::class, properties={"price"})
 * @ApiFilter(PropertyFilter::class)
 * @ORM\Entity(repositoryClass="App\Repository\CheeseListingRepository")
 */
class CheeseListing extends BaseEntity
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @Assert\NotBlank()
     * @Assert\Length(
     *     min=2,
     *     max=50,
     *     maxMessage="Describe your cheese in 50 chars or less"
     * )
     * @Groups({"cheese_listing:read", "cheese_listing:write"})
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * @Assert\NotBlank()
     * @Groups({"cheese_listing:read", "create"})
     * @ORM\Column(type="text")
     */
    private $description;

But, if I have my CheeseListing extend BaseEntity and the BaseEntity class contains a property that has a serialization group of "create", it does not show up if I keep my CheeseListing as defined above. BUT if I remove the resource level normalizationContext and denormalizationContext lines it shows up then... so not sure why it is completely being ignored.

The 2 lines I needed to remove to get the "create" serialization group to show up for the POST operation. But... with removing those, I now lose my Read and Write definitions and groups.

 *     normalizationContext={"groups"={"cheese_listing:read"}, "swagger_definition_name"="Read"},
 *     denormalizationContext={"groups"={"cheese_listing:write"}, "swagger_definition_name"="Write"},

My BaseEntity.php file:

namespace App\Entity;

use Symfony\Component\Serializer\Annotation\Groups;

class BaseEntity
{
    /**
     * @Groups({"create"})
     */
    protected $someProperty;

    public function getSomeProperty()
    {
        return $this->someProperty;
    }

    public function setSomeProperty($someProperty): void
    {
        $this->someProperty = $someProperty;
    }
}

Any idea why I need to remove the resource level normalization and denormalization contexts to get groups in parent classes to show up?

tec4 commented 4 years ago

I've just checked and I'm having the same issue when trying to use a BaseEntityTrait with the same code as BaseEntity to try and work around the inheritance. Using the trait, I also need to remove the resource level serialization groups in order to get the $someProperty, with the serialization group of "create" to be respected.

tec4 commented 4 years ago

I've created a repo to reproduce this issue here. The readme contains simple instructions to build the project and reproduce the issue.

It has one API Resource called "Example" which extends from a simple Base class and includes some simple properties from BaseTrait. They look as follows:

Example Entity

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @ApiResource(
 *     normalizationContext={"groups"={"read"}},
 *     denormalizationContext={"groups"={"write"}},
 *     collectionOperations={
 *          "post"={
 *              "normalization_context"={"groups"={"create"}},
 *              "denormalization_context"={"groups"={"create"}},
 *          }
 *     }
 * )
 * @ORM\Entity(repositoryClass="App\Repository\ExampleRepository")
 */
class Example extends Base
{
    use BaseTrait;

    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @Groups({"read", "write", "create"})
     * @ORM\Column(type="string", length=255)
     */
    private $example;

    /**
     * @Groups({"create"})
     * @ORM\Column(type="string", length=255)
     */
    private $createOnlyProperty;

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

    public function getExample(): ?string
    {
        return $this->example;
    }

    public function setExample(string $example): self
    {
        $this->example = $example;

        return $this;
    }

    public function getCreateOnlyProperty(): ?string
    {
        return $this->createOnlyProperty;
    }

    public function setCreateOnlyProperty(string $createOnlyProperty): self
    {
        $this->createOnlyProperty = $createOnlyProperty;

        return $this;
    }
}

Base Class

<?php

namespace App\Entity;

use Symfony\Component\Serializer\Annotation\Groups;

class Base
{
    /**
     * @var string
     * @Groups({"read", "write", "create"})
     */
    private $basePropertySuccess;

    /**
     * @var string
     * @Groups({"create"})
     */
    private $basePropertyFail;

    public function getBasePropertySuccess(): ?string
    {
        return $this->basePropertySuccess;
    }

    public function setBasePropertySuccess(string $basePropertySuccess): self
    {
        $this->basePropertySuccess = $basePropertySuccess;

        return $this;
    }

    public function getBasePropertyFail(): ?string
    {
        return $this->basePropertyFail;
    }

    public function setBasePropertyFail(string $basePropertyFail): self
    {
        $this->basePropertyFail = $basePropertyFail;

        return $this;
    }
}

BaseTrait

<?php

namespace App\Entity;

use Symfony\Component\Serializer\Annotation\Groups;

trait BaseTrait
{
    /**
     * @var string
     * @Groups({"read", "write", "create"})
     */
    private $traitPropertySuccess;

    /**
     * @var string
     * @Groups({"create"})
     */
    private $traitPropertyFail;

    public function getTraitPropertySuccess(): ?string
    {
        return $this->traitPropertySuccess;
    }

    public function setTraitPropertySuccess(string $traitPropertySuccess): self
    {
        $this->traitPropertySuccess = $traitPropertySuccess;

        return $this;
    }

    public function getTraitPropertyFail(): ?string
    {
        return $this->traitPropertyFail;
    }

    public function setTraitPropertyFail(string $traitPropertyFail): self
    {
        $this->traitPropertyFail = $traitPropertyFail;

        return $this;
    }
}

As is, the Models section looks like: image

But, if I remove the following lines from the Example entity:

 *     normalizationContext={"groups"={"read"}},
 *     denormalizationContext={"groups"={"write"}},

It now looks like: image

I would have expected that the all of the properties shown under the last image for the Example-create model would be present no matter what since they all include the "create" serialization group.

teohhanhui commented 4 years ago

Yup, definitely looks like a bug to me.

teohhanhui commented 4 years ago

Does it work as expected when you actually try doing POST /cheese_listings?

Could be a bug with the Swagger DocumentationNormalizer.

tec4 commented 4 years ago

@teohhanhui - Interesting, I actually had not tried to post data to the endpoint since I assumed the docs represented an accurate representation of what could/could not be updated.

I Just tested posting to the http://localhost:8000/api/examples endpoint (this is in the example bug repository I created and linked in my 3rd comment in this issue report) and it looks like the serializer is working correctly and persisting correctly to the DB. As you suspected it does look to be an issue with the translation to the docs itself.

image

image

teohhanhui commented 4 years ago

Thanks for the confirmation. It helps to know where to look for the problem. The code for the Swagger DocumentationNormalizer is quite a minefield, but someone will have to dive in. :see_no_evil:

tec4 commented 4 years ago

Haha thanks @teohhanhui!