3

on official Api-Platform website there is a General Design Considerations page.

Last but not least, to create Event Sourcing-based systems, a convenient approach is:

  • to persist data in an event store using a custom data persister
  • to create projections in standard RDBMS (Postgres, MariaDB...) tables or views
  • to map those projections with read-only Doctrine entity classes and to mark those classes with @ApiResource

You can then benefit from the built-in Doctrine filters, sorting, pagination, auto-joins, etc provided by API Platform.

So, I tried to implement this approach with one simplification (one DB is used, but with separated reads and writes).

But failed... there is a problem, which I don't know how to resolve, so kindly asking you for a help!

I created a User Doctrine entity and annotated fields I want to expose with @Serializer\Groups({"Read"}). I will omit it here as it's very generic.

User resource in yaml format for api-platform:

# config/api_platform/entities/user.yaml

App\Entity\User\User:
    attributes:
        normalization_context:
            groups: ["Read"]
    itemOperations:
        get: ~
    collectionOperations:
        get:
            access_control: "is_granted('ROLE_ADMIN')"

So, as it's shown above User Doctrine entity is read-only, as only GET methods are defined.

Then I created a CreateUser DTO:

# src/Dto/User/CreateUser.php

namespace App\Dto\User;

use App\Validator as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;

final class CreateUser
{
    /**
     * @var string
     * @Assert\NotBlank()
     * @Assert\Email()
     * @AppAssert\FakeEmailChecker()
     */
    public $email;
    /**
     * @var string
     * @Assert\NotBlank()
     * @AppAssert\PlainPassword()
     */
    public $plainPassword;
}

CreateUser resource in yaml format for api-platform:

# config/api_platform/dtos/create_user.yaml

App\Dto\User\CreateUser:
    itemOperations: {}
    collectionOperations:
        post:
            access_control: "is_anonymous()"
            path: "/users"
            swagger_context:
                tags: ["User"]
                summary: "Create new User resource"

So, here you can see that only one POST method is defined, exactly for creation of a new User.

And here what router shows:

$ bin/console debug:router
---------------------------------- -------- -------- ------ -----------------------
Name                               Method   Scheme   Host   Path
---------------------------------- -------- -------- ------ -----------------------
api_create_users_post_collection   POST     ANY      ANY    /users
api_users_get_collection           GET      ANY      ANY    /users.{_format}
api_users_get_item                 GET      ANY      ANY    /users/{id}.{_format}

I also added a custom DataPersister to handle POST to /users. In CreateUserDataPersister::persist I used Doctrine entity to write data, but for this case it doesn't matter as Api-platform do not know anything about how DataPersister will write it. So, from the concept - it's a separation of reads and writes.

Reads are performed by Doctrine's DataProvider shipped with Api-platform, and writes are performed by custom DataPersister.

# src/DataPersister/CreateUserDataPersister.php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use App\Dto\User\CreateUser;
use App\Entity\User\User;
use Doctrine\ORM\EntityManagerInterface;

class CreateUserDataPersister implements DataPersisterInterface
{
    private $manager;

    public function __construct(EntityManagerInterface $manager)
    {
        $this->manager = $manager;
    }

    public function supports($data): bool
    {
        return $data instanceof CreateUser;
    }

    public function persist($data)
    {
        $user = new User();
        $user
            ->setEmail($data->email)
            ->setPlainPassword($data->plainPassword);

        $this->manager->persist($user);
        $this->flush();

        return $user;
    }

    public function remove($data)
    {

    }
}

When I perform a request to create new User:

POST https://{{host}}/users
Content-Type: application/json

{
  "email": "test@custom.domain",
  "plainPassword": "123qweQWE"
}

Problem! I'm getting a 400 response ... "hydra:description": "No item route associated with the type "App\Dto\User\CreateUser"." ...

However, a new record is added to database, so custom DataPersister works ;)

According to General Design Considerations separations of writes and reads are implemented, but not working as expected.

I'm pretty sure, that I could be missing something to configure or implement. So, that's why it's not working.

Would be happy to get any help!

Update 1:

The problem is in \ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameResolver::getRouteName(). At lines 48-59 it iterates through all routes trying to find appropriate route for:

  • $operationType = 'item'
  • $resourceClass = 'App\Dto\User\CreateUser'

But $operationType = 'item' is defined only for $resourceClass = 'App\Entity\User\User', so it fails to find the route and throws an exception.

Update 2:

So, the question could sound like this:

How it's possible to implement separation of reads and writes (CQS?) using Doctrine entity for reads and DTO for writes, both residing on the same route, but with different methods?

Update 3:

Data Persisters

  • store data to other persistence layers (ElasticSearch, MongoDB, external web services...)
  • not publicly expose the internal model mapped with the database through the API
  • use a separate model for read operations and for updates by implementing patterns such as CQRS

Yes! I want that... but how to achieve it in my example?

3 Answers3

3

Short Answer

The problem is that the Dto\User\CreateUser object is getting serialized for the response, when in fact, you actually want the Entity\User to be returned and serialized.

Long Answer

When API Platform serializes a resource, they will generate an IRI for the resource. The IRI generation is where the code is puking. The default IRI generator uses the Symfony Router to actually build the route based on the API routes created by API Platform.

So for generating an IRI on an entity, it will need to have a GET item operation defined because that is the route that will be the IRI for the resource.

In your case, the DTO doesn't have a GET item operation (and shouldn't have one), but when API Platform tries to serialize your DTO, it throws that error.

Steps to Fix

From your code sample, it looks like the User is being returned, however, it's clear from the error that the User entity is not the one being serialized.

One thing to do would be to install the debug-pack, start the dump server with bin/console server:dump, and add a few dump statements in the API Platform WriteListener: ApiPlatform\Core\EventListener\WriteListener near line 53:

dump(["Controller Result: ", $controllerResult]);
$persistResult = $this->dataPersister->persist($controllerResult);
dump(["Persist Result: ", $persistResult]);

The Controller Result should be an instance of your DTO, the Persist Result should be an instance of your User entity, but I'm guessing it's returning your DTO.

If it is returning your DTO, you need to just debug and figure out why the DTO is being returned from the dataPersister->persist instead of the User entity. Maybe you have other data persisters or things in your system that can be causing conflict.

Hopefully this helps!

RJ Garcia
  • 31
  • 1
  • Hey RJ Garcia, thanks for an answer! I updated my post with a few clarifications (Update 1-3), please take a look! – Yury Garris Aug 22 '18 at 08:53
  • This is so terribly late, but API Platform 2.4 is going to be released soon, and we've been working hard at making CQRS integration much cleaner. I'd give it another look once 2.4 gets released. – RJ Garcia Feb 08 '19 at 23:32
0

You need to send the "id" in your answer.

If User is Doctrine entity, use:

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

If User isn't Doctrine entity, use:

/**
 * @Assert\Type(type="integer")
 * @ApiProperty(identifier=true)
 */
private $id;

Anyway, your answer would be like this:

{
  "id": 1, // Your unique id of User
  "email": "test@custom.domain",
  "plainPassword": "123qweQWE"
}

P.S.: sorry for my english :)

Nathan Hughes
  • 94,330
  • 19
  • 181
  • 276
0

Only Work in the 2.4 version but really helpful.

Just add output_class=false for the CreateUserDTO and everything will be fine for POST|PUT|PATCH

output_class to false allow you to bypass the get item operation. You can see that in the ApiPlatform\Core\EventListener#L68.