api-platform / core

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

When using DTO embedded entity is not deserialized using IRI #3795

Closed informatic-revolution closed 3 years ago

informatic-revolution commented 3 years ago

API Platform version(s) affected: 2.5.7

Description
When using DTO, it does not deserialize an entity as an IRI, using the attribute readableLink of ApiProperty annotation does not have any effect.

How to reproduce

final class EstablishmentOutput
{
    /**
     * @Groups({"establishment:read"})
     */
    public string $name;

    /**
     * @Groups({"establishment:read"})
     */
    public Company $company;
}

Executing tests give me the following response

Failed asserting that an array has the subset Array &0 (
    'name' => 'Establishment 1'
    'company' => '/companies/cb462da6-47a4-4a5c-a5d1-c9c9826da27e'
    'address' => Array &1 (
        'street' => 'long-island 123'
        'postalCode' => '22014'
        'city' => 'Madrid'
        'country' => 'ES'
    )
    'phoneNumber' => '+34666555444'
    '@id' => '/establishments/b166a812-6891-4030-91f4-e2730ac5187b'
    'email' => 'foo@bar.com'
    'enabled' => false
).
--- Expected
+++ Actual
@@ @@
   '@type' => 'Establishment',
   '@id' => '/establishments/b166a812-6891-4030-91f4-e2730ac5187b',
   'name' => 'Establishment 1',
-  'company' => '/companies/cb462da6-47a4-4a5c-a5d1-c9c9826da27e',
+  'company' => 
+  array (
+    '@context' => 
+    array (
+      '@vocab' => 'http://example.com/docs.jsonld#',
+      'hydra' => 'http://www.w3.org/ns/hydra/core#',
+      'name' => 'CompanyOutput/name',
+      'identificationNumber' => 'CompanyOutput/identificationNumber',
+      'address' => 'CompanyOutput/address',
+    ),
+    '@type' => 'Company',
+    '@id' => '/companies/cb462da6-47a4-4a5c-a5d1-c9c9826da27e',
+  ),
   'address' => 
   array (
     '@context' =>
rugolinifr commented 3 years ago

Do Company properties have @Groups({"establishment:read"}) annotations too?

informatic-revolution commented 3 years ago

No the company output code is the following

final class CompanyOutput
{
    /**
     * @Groups({"company:read"})
     */
    public string $name;

    /**
     * @Groups({"company:read"})
     */
    public string $identificationNumber;

    /**
     * @Groups({"company:read"})
     */
    public AddressOutput $address;
    }
}
ghost commented 3 years ago

@informatic-revolution Have you been able to solve this problem? I am currently facing the same problem and I have no idea how to solve it properly.

julienkirsch commented 3 years ago

@mfu-aroche you can take a look a the long conversation I had here with weaverryan about this issue

https://symfonycasts.com/screencast/api-platform-extending/collections-readable-link

ghost commented 3 years ago

Thanks @julienkirsch, I'll give it a try.

ghost commented 3 years ago

I may have talk a bit to quickly... my problem isn't exactly the same as I only have one DTO. Here is my code from a dummy project that mimics the real project context (that's why the DTO looks useless):

<?php
declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\DataTransferObject\ArticleDTO;
use App\Repository\ArticleRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

/**
 * @ORM\Entity(repositoryClass=ArticleRepository::class)
 * @ApiResource(
 *     attributes={
 *          "order"={
 *              "createdAt": "ASC"
 *          }
 *     },
 *     output=ArticleDTO::class
 * )
 */
class Article
{
    /**
     * @ORM\Id
     * @ORM\Column(type="uuid")
     */
    private UuidInterface $id;

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

    /**
     * @ORM\Column(type="text")
     */
    private string $content;

    /**
     * @ORM\Column(type="datetime_immutable")
     */
    private DateTimeImmutable $createdAt;

    /**
     * @ORM\ManyToOne(targetEntity=Author::class, inversedBy="articles")
     * @ORM\JoinColumn(nullable=false)
     */
    private Author $author;

    public function __construct(
        Author $author,
        string $headline,
        string $content,
        UuidInterface $id = null
    )
    {
        $this->id = $id ?? Uuid::uuid4();
        $this->author = $author;
        $this->headline = $headline;
        $this->content = $content;
        $this->createdAt = new DateTimeImmutable();
    }

    // Getters and setters
}
<?php
declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\AuthorRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

/**
 * @ORM\Entity(repositoryClass=AuthorRepository::class)
 * @ApiResource(
 *     attributes={
 *          "order"={
 *              "lastname": "ASC",
 *              "firstname": "ASC"
 *          }
 *     }
 * )
 */
class Author
{
    /**
     * @ORM\Id
     * @ORM\Column(type="uuid")
     */
    private UuidInterface $id;

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

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

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

    /**
     * @var Collection|Article[]
     * @ORM\OneToMany(targetEntity=Article::class, mappedBy="author")
     */
    private Collection $articles;

    public function __construct(
        string $email,
        string $firstname,
        string $lastname,
        UuidInterface $id = null
    )
    {
        $this->id = $id ?? Uuid::uuid4();
        $this->email = $email;
        $this->firstname = $firstname;
        $this->lastname = $lastname;
        $this->articles = new ArrayCollection();
    }

    // Getters and setters
}
<?php
declare(strict_types=1);

namespace App\DataTransferObject;

use App\Entity\Author;
use DateTimeImmutable;
use Ramsey\Uuid\UuidInterface;

class ArticleDTO
{
    public UuidInterface $id;

    public string $headline;

    public string $content;

    public DateTimeImmutable $createdAt;

    public Author $author;
}
<?php
declare(strict_types=1);

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\DataTransferObject\ArticleDTO;
use App\Entity\Article;

final class ArticleDataTransformer implements DataTransformerInterface
{
    /**
     * @param Article $object
     * @param string  $to
     * @param array   $context
     *
     * @return object|void
     */
    public function transform($object, string $to, array $context = [])
    {
        if ($to === ArticleDTO::class) {
            $output = new ArticleDTO();
            $output->id = $object->getId();
            $output->author = $object->getAuthor();
            $output->headline = $object->getHeadline();
            $output->content = $object->getContent();
            $output->createdAt = $object->getCreatedAt();

            return $output;
        }
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return $data instanceof Article && $to === ArticleDTO::class;
    }
}

This is the output I get when not using the DTO:

{
  "id": "fa7419dc-9dd7-4f5d-af7d-cebaaaecfefb",
  "headline": "First article",
  "content": "Lorem ipsum dolor sit amet",
  "createdAt": "2021-02-12T14:23:47+01:00",
  "author": "/api/authors/90655472-732f-4136-813e-a8c70ea2f7e7"
}

With the DTO enabled:

{
  "id": "fa7419dc-9dd7-4f5d-af7d-cebaaaecfefb",
  "headline": "First article",
  "content": "Lorem ipsum dolor sit amet",
  "createdAt": "2021-02-12T14:23:47+01:00",
  "author": {
    "id": "90655472-732f-4136-813e-a8c70ea2f7e7",
    "email": "some.one@example.com",
    "firstname": "Some",
    "lastname": "One",
    "articles": [
      "/api/articles/fa7419dc-9dd7-4f5d-af7d-cebaaaecfefb",
      "/api/articles/adf64e4b-3319-493a-b0f6-88d38ac993b0"
    ]
  }
}

How do I manage to get the author serialized as an IRI ?

julienkirsch commented 3 years ago

Could you send your composer dependencies please ?

soyuka commented 3 years ago

Could you give us a full reproducer please? Not sure to understand the use case here. If you need only the IRI why use a DTO?

ghost commented 3 years ago

@julienkirsch here it is:

"require": {
    "php": ">=7.4",
    "ext-ctype": "*",
    "ext-iconv": "*",
    "api-platform/core": "^2.6",
    "composer/package-versions-deprecated": "1.11.99.1",
    "doctrine/annotations": "^1.0",
    "doctrine/doctrine-bundle": "^2.2",
    "doctrine/doctrine-migrations-bundle": "^3.0",
    "doctrine/orm": "^2.8",
    "nelmio/cors-bundle": "^2.1",
    "phpdocumentor/reflection-docblock": "^5.2",
    "ramsey/uuid-doctrine": "^1.6",
    "symfony/asset": "5.2.*",
    "symfony/console": "5.2.*",
    "symfony/dotenv": "5.2.*",
    "symfony/expression-language": "5.2.*",
    "symfony/flex": "^1.3.1",
    "symfony/framework-bundle": "5.2.*",
    "symfony/property-access": "5.2.*",
    "symfony/property-info": "5.2.*",
    "symfony/proxy-manager-bridge": "5.2.*",
    "symfony/security-bundle": "5.2.*",
    "symfony/serializer": "5.2.*",
    "symfony/twig-bundle": "5.2.*",
    "symfony/validator": "5.2.*",
    "symfony/yaml": "5.2.*"
},
"require-dev": {
    "symfony/debug-bundle": "^5.2",
    "symfony/maker-bundle": "^1.28",
    "symfony/monolog-bundle": "^3.0",
    "symfony/stopwatch": "^5.2",
    "symfony/var-dumper": "^5.2",
    "symfony/web-profiler-bundle": "^5.2"
},

@soyuka the real project is close to this example, but we do have extra fields (computed) in the DTO though. Like this example, we do have an entity referenced in the DTO. And we want it to be normalized like any other related resources (i.e. IRI by default, or fields if serialization groups are specified). Does it make sense to you?

It's not a deal breaker for us, as we have other options, but it might be useful to have almost the same normalizers for DTO as standard resources (at least regarding embedded/subresources).

julienkirsch commented 3 years ago

@mfu-aroche i don't see anything missing or strange in your composer.json file

ghost commented 3 years ago

Yes, neither do I... that's why it's weird. I'll try to downgrade API Platform. Maybe it's a kind of "regression".

soyuka commented 3 years ago

I don't get the use of the DTO here. About the first bug report, the context of non-resources objects are embeded to match the Json-LD specification as we can not create a context for it in the entrypoint.

I'm working on a solution for you!

soyuka commented 3 years ago

Few things are wrong here and the first one is that the ValueObject (or DTO) is not a Resource and therefore has no identifiers (maybe added through https://github.com/api-platform/core/pull/3946). No identifiers means no IRI to normalize. I really don't get how your DTO was working before and I wish I had a full reproduction case @informatic-revolution

In the meantime, just use an ApiResource over the embed DTO, you can disable its operations if needed and you'll have an IRI.

Closing, @mfu-aroche please open a new issue as yours is slightly different.

kgilden commented 3 years ago

@soyuka I'm sorry to comment on a closed issue, feel free to let me know if I should open a new one.

Just so that we would all be on the same page, let me rephrase the original problem.

Class Parent has an output class ParentOutput. Class Parent has a *-to-one relationship to class Child. A custom data transformer ParentToParentOutputTransformer knows how to transform Parent to ParentOutput. Suppose we are exposing both Parent and Child via the API. The classes look as follows.

/**
 * @ApiResource(output = ParentOutput::class)
 */
class Parent
{
    public int $id;
    public Child $child;
}

class ParentOutput
{
    public int $id;
    public Child $child;
}

/**
 * @ApiResource
 */
class Child
{
    public int $id;
}

When making a GET /parents/42 request, the following is (IMO incorrectly) returned.

{
  "@context": { /* ... */ },
  "@type": "Parent",
  "@id": "/parent/42",
  "id": 42,
  "child": {
    "@context": " ... ",
    "@id": "/children/1",
    "id": 1,
  }
}

I would expect the following to be returned. And this is indeed returned, if Parent didn't have a different output class.

{
  "@context": { /* ... */ },
  "@type": "Parent",
  "@id": "/parent/42",
  "id": 42,
  "child": "/children/1"
}

@informatic-revolution is not talking about the related object being a non-resource, they're talking about the root object being a DTO, which references a resource. But instead of generating an IRI, the reference is being serialized.

Is this really the expected behavior?