API Platform is a powerful API building tool built on top of Symfony framework. By using it, it is almost trivial to bootstrap your API resources with CRUD functionalities by annotating your entities with @ApiResource()
annotation. And this is amazing.
But as it turns out, there are some drawbacks to that approach.
By using it, you are coupling your domain entities with the API Platform resources, you are exposing your domain entities to the client, and don't even get me started on the DDD principles.
Luckily, amazing people behind API platform have thought about that, and have added to their framework a mechanism to decouple your entities from your API resources.
In this post, I'm going to show you how to configure your API resources in an XML format, decoupled from your domain models, and might add a cherry on top regarding serialization.
Let's make our entities/domain models
<?php
namespace App\Entity;
class Post
{
private int $id;
private string $title;
private Comment $comment;
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getComment(): Comment
{
return $this->comment;
}
public function setcomment(Comment $comment): void
{
$this->comment = $comment;
}
}
<?php
namespace App\Entity;
class Comment
{
private int $id;
private string $text;
public function getText(): string
{
return $this->text;
}
public function setText(string $text): void
{
$this->text = $text;
}
}
Like so, now we have our domain models.
Let's repeat what we are aiming for:
- Our domain models to be decoupled from the API platform configuration
- Our domain models to not be the same as API resources
Let's configure our API resources in an XML format
First, let's tweak our API Platform configuration file to read mappings from our XML file:
// config/packages/api_platform.yaml
api_platform:
mapping:
paths: ['%kernel.project_dir%/config/api_platform/resources.xml']
Now after we have told the API Platform where to look for API resources configuration, the only thing left is to create our XML file.
// config/api_platform/resources.xml
<?xml version="1.0" encoding="UTF-8" ?>
<resources xmlns="https://api-platform.com/schema/metadata"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://api-platform.com/schema/metadata
https://api-platform.com/schema/metadata/metadata-2.0.xsd">
<resource class="App\Entity\Post">
<attribute name="normalization_context">
<attribute name="groups">post:read</attribute>
</attribute>
<attribute name="denormalization_context">
<attribute name="groups">post:write</attribute>
</attribute>
<itemOperations>
<itemOperation name="get">
<attribute name="method">GET</attribute>
<attribute name="output">App\Dto\PostDTO</attribute>
</itemOperation>
</itemOperations>
<collectionOperations>
<collectionOperation name="post">
<attribute name="method">POST</attribute>
<attribute name="input">App\Dto\PostDto</attribute>
<attribute name="output">App\Dto\PostDto</attribute>
</collectionOperation>
</collectionOperations>
</resource>
Our API resources configuration is pretty simple. We have made our model available as a resource, supporting a single item retrieval and item creation, but with a twist. We've also added input and output attributes to those operations.
Input and output attributes are saying which classes are accepted as objects for reading and creating a resource instance.
Or as the docs say:
The input attribute is used during the deserialization process, when transforming the user-provided data to a resource instance. Similarly, the output attribute is used during the serialization process.
We have also added attributes for serialization groups (denormalization and normalization contexts), which allow us to choose which resource attributes will be exposed during a serialization process.
Next, we should create our DTOs.
Let's create our DTOs.
<?php
namespace App\Dto;
final class PostDto
{
public int $id;
public string $title;
public CommentDto $comment;
}
<?php
namespace App\Dto;
final class CommentDto
{
public int $id;
public string $text;
}
Great, that was easy.
Let's continue and create data transformers that will be in charge of converting our input DTO to the resource object and the other way around.
Creating data transformers
An input transformer:
<?php
namespace App\DataTransformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use ApiPlatform\Core\Validator\ValidatorInterface;
use App\Dto\PostDto;
use App\Entity\Post;
final class PostInputTransformer implements DataTransformerInterface
{
/**
* @var ValidatorInterface
*/
private $validator;
public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}
public function transform(PostDto $object, string $to, array $context = []): Post
{
$this->validator->validate($object);
$post = new Post();
$post->setTitle($object->title);
$post->setComment($object->comment);
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
if ($data instanceof Post) {
return false;
}
return $to === Post::class && ($context['input']['class'] ?? null) !== null;
}
}
And an output transformer:
<?php
namespace App\DataTransformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\PostDto;
use App\Entity\Post;
final class PostOutputTransformer implements DataTransformerInterface
{
public function transform(Post $object, string $to, array $context = []): PostDto
{
$output = new PostDto();
$output->title = $object->getTitle();
$output->comment = $object->getComment();
return $output;
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
return $to === PostDto::class && $data instanceof Post;
}
}
And we are done!!!
We have successfully achieved our goals:
- Our models are decoupled from the API platform configuration
- Our models are not resources exposed through the API
Congrats!
And as a cherry on top, let's make use of those aforementioned serialization groups.
Serialization groups configuration in XML format
Like we already did, let's update our framework configuration file first:
// config/packages/framework
framework:
serializer:
mapping:
paths: ['%kernel.project_dir%/config/serializer/serialization.xml']
And now we can create that XML file and add groups to all attributes that we want to be serialized and deserialized:
<?xml version="1.0" ?>
<serializer xmlns="http://symfony.com/schema/dic/serializer-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/serializer-mapping
https://symfony.com/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd"
>
<class name="App\Dto\PostDto">
<attribute name="id">
<group>post:read</group>
</attribute>
<attribute name="title">
<group>post:read</group>
<group>post:write</group>
</attribute>
<attribute name="comment">
<group>post:read</group>
<group>post:write</group>
</attribute>
</class>
<class name="App\Dto\CommentDto">
<attribute name="id">
<group>post:read</group>
</attribute>
<attribute name="text">
<group>post:read</group>
<group>post:write</group>
</attribute>
</class>
</serializer>
What we configured in serialization.xml
is:
- When /GET request is sent to retrieve a Post, our API will expose:
{
"id": 1,
"title": "Title",
"comment": {
"id": 1,
"text": "Text"
}
}
- When a /POST request is sent to create a new Post, we allow a Comment to be created together with it, and payload sent to the API should look like:
{
"title": "Title",
"comment": {
"text": "Text"
}
}
Thank you for sticking with me!
I hope that this post has helped you in any way.
Happy configuring!
Top comments (0)