!:

Using DTO and Data Transformer in Api Platform Project

Introduction

In this small article I will show how to use DTO and DataTransformer in Api Platform , for this I will use a very simple use case, we will create a user entity, and when a user wants to reset his password he should send his email to api and the api will send him an email containing a url to reset the password.

The complete source code is Here .

Requirements:

  • docker & docker-compose

Setup the docker environment

I will use a simple docker-compose file to use the PHP 8.1 and a simple database container to test with.

First let us create a new directory dto-demo and move to it then create an a docker-compose.yaml file.

1 2 3 mkdir dto-demo cd dto-demo touch docker-compose.yaml

Copy this example of code to the docker-compose.yaml file:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 version: '3.9' networks: dev: services: web: image: nginx:alpine restart: unless-stopped ports: - 80:80 volumes: - .:/var/www:delegated - ./site.conf:/etc/nginx/conf.d/default.conf networks: - dev php: restart: unless-stopped container_name: php-container networks: - dev build: context: './.docker/php' args: USER_ID: ${USER_ID} GROUP_ID: ${GROUP_ID} volumes: - './:/var/www:delegated' depends_on: - db db: image: 'mariadb:latest' environment: MYSQL_PASSWORD: 'root' MYSQL_ROOT_PASSWORD: 'root' MYSQL_DATABASE: app networks: - dev volumes: - db_data:/var/lib/mysql ports: - '3306:3306' volumes: db_data:

We used Nginx as a web server so I add a basic configuration in the root directory inside the filename site.conf , create that file and copy this code:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 server { listen 80; server_name localhost; root /var/www/public; add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; add_header X-Content-Type-Options "nosniff"; index index.html index.htm index.php; charset utf-8; location / { root /var/www/; try_files /public/$uri /public/$uri /assets/$uri /index.php?$query_string; } location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } error_page 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 421 422 423 424 425 426 428 429 431 451 500 501 502 503 504 505 506 507 508 510 511 /error.html; location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; fastcgi_buffers 16 16k; fastcgi_buffer_size 32k; } location ~ /\.(?!well-known).* { deny all; } }

I created a simple Dockerfile to build a php 8.1 image with a few common extensions, symfony cli and composer, I also created a user for the container to avoid the permission problems, copy that content of this file and move it to /.docker/php folder as mentioned in the context build of the php container.

1 2 3 mkdir -p .docker/php cd .docker/php touch Dockerfile

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 FROM php:8.1-fpm ARG USER_ID ARG GROUP_ID RUN apt-get update && apt-get install -y wget git RUN apt-get update && apt-get install -y libzip-dev libicu-dev && docker-php-ext-install pdo zip intl opcache RUN pecl install apcu && docker-php-ext-enable apcu RUN docker-php-ext-install mysqli pdo_mysql RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer RUN curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | bash RUN apt-get install symfony-cli WORKDIR /var/www RUN groupadd -f --gid $GROUP_ID user RUN adduser --disabled-password --gecos '' --uid $USER_ID --gid $GROUP_ID user USER user EXPOSE 9000

Then you should build the docker containers and turn them up:

1 2 3 USER_ID=$(id -u) GROUP_ID=$(id -g) docker-compose build docker-compose up -d

Installation of Symfony project

First let us go inside the php container where we can use the symfony cli and composer to install some dependencies.

1 2 3 4 5 docker exec -it php-container bash composer require symfony/webapp-pack #Install symfony webapp composer require symfony/flex #Install symfony flex to get the folder structure and the basic configuration composer require symfony/runtime symfony/yaml symfony/dotenv

Add this lines to the composer.json:

1 2 3 4 5 6 7 8 9 10 "autoload": { "psr-4": { "App\\": "src/" } }, "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } },

And run the command:

1 composer dumpautoload

I use git to upload my code to github, so let's initialize a new repository.

1 git init --name dto/demo --type project --author "username <email@gmail.com>" # click Enter to skip all questions

and run this commands to set the DATABASE_URL environment variable in the .env.local file and create the database:

1 2 3 echo DATABASE_URL="mysql://root:root@db:3306/app?serverVersion-8&charset=utf8mb4" > .env.local symfony console doctrine:database:create --if-not-exists

Installation of Api Platform and create a User entity

Just with this command api platform bundle will be installed and configured:

1 composer require api

Let us create a new user with symfony maker component:

1 2 3 4 5 symfony console make:user #Less all the options by default symfony console make:migration symfony console doctrine:migrations:migrate

Cool, until now, we have a user table in our database with the columns email and password, now will make a post endpoint, when the user can send us his email to reset his password, and he should get the response of his information without showing the password, we can use symfony serialization groups, but today we will use DTOs.

DataTransformers and DTO

the UserResetPasswordInput class contains the email that we will use to get the user, and after sending the email we will transform the user to an UserResetPasswordOutput. This is like transforming the UserResetPasswordInput to a User and then transforming the User to a UserResetPasswordOutput.

So make sure to create those two PHP classes with this content:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 namespace App\Dto; use Symfony\Component\Validator\Constraints as Assert; final class UserResetPasswordInput { #[Assert\Email] #[Assert\NotNull] #[Assert\NotBlank] private string $email; public function getEmail(): string { return $this->email; } public function setEmail(string $email): self { $this->email = $email; return $this; } }

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 namespace App\Dto; final class UserResetPasswordOutput { private string $email; private int $id; public function getEmail(): string { return $this->email; } public function setEmail(string $email): self { $this->email = $email; return $this; } public function getId(): int { return $this->id; } public function setId(int $id): self { $this->id = $id; return $this; } }

So to make this works let's change the User entity to specify the input and the output like the following code that you will need to add it to the User entity just above the class declaration:

1 2 3 4 5 6 7 8 9 10 11 12 13 #[ApiResource( itemOperations: [ 'GET' => [], ], collectionOperations:[ 'post' => [ 'input' => UserResetPasswordInput::class, 'output' => UserResetPasswordOutput::class, 'status' => Response::HTTP_OK, ] ], )] class User ...

The status property is to change the response status code from 201 to 200.

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. This class represents how the User resource will be represented in the Response.

Now we need the transformers to do the actual work, we need the UserResetPasswordInputTransformer to transform the input that contains an email to the User object, and the UserResetPasswordOutputTransformer to transform the user to a UserResetPasswordOutput.

To do this, create a folder DataTransformer inside the src directory and copy the following files:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 namespace App\DataTransformer; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Validator\ValidatorInterface; use App\Dto\UserResetPasswordInput; use App\Entity\User; use App\Repository\UserRepository; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Security\Core\User\UserInterface; use UnexpectedValueException; final class UserResetPasswordInputTransformer implements DataTransformerInterface { public function __construct( private readonly ValidatorInterface $validator, private readonly UserRepository $userRepository ){ } public function supportsTransformation($data, string $to, array $context = []): bool { $input = $context['input'] ?? null; $inputClass = $input['class'] ?? null; return User::class === $to && UserResetPasswordInput::class === $inputClass; } public function transform($object, string $to, array $context = []): User { if (!$object instanceof UserResetPasswordInput) { throw new UnexpectedValueException('Transformation operation not allowed'); } $this->validator->validate($object); $user = $this->userRepository->findOneByEmail($object->getEmail()); if (!$user instanceof UserInterface) { $message = sprintf('User with the email "%s" not found', $object->getEmail()); throw new UnprocessableEntityHttpException($message); } // Send an email to reset the password here. return $user; } }

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 namespace App\DataTransformer; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use App\Dto\UserResetPasswordOutput; use App\Entity\User; use UnexpectedValueException; final class UserResetPasswordOutputTransformer implements DataTransformerInterface { public function supportsTransformation($data, string $to, array $context = []): bool { $output = $context['output'] ?? null; $outputClass = $output['class'] ?? null; return $data instanceof User && UserResetPasswordOutput::class === $outputClass; } public function transform($object, string $to, array $context = []): ?UserResetPasswordOutput { if (!$object instanceof User) { throw new UnexpectedValueException('Transformation operation not allowed'); } return (new UserResetPasswordOutput()) ->setEmail($object->getEmail()) ->setId($object->getId()) ; } }

To test our endpoint let's create some fixture and simply a user, that we can test with.

1 2 composer require orm-fixtures --dev symfony console make:fixtures UserFixtures

Copy this to UserFixtures class and you're done:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 namespace App\DataFixtures; use App\Entity\User; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class UserFixtures extends Fixture { public function __construct( private UserPasswordHasherInterface $passwordHasher ){ } public function load(ObjectManager $manager): void { $user = new User(); $user->setEmail('user@gmail.com'); $user->setPassword( $this->passwordHasher->hashPassword($user, '123456') ); $manager->persist($user); $manager->flush(); } }

Load the fixtures to the database:

1 symfony console doctrine:fixtures:load --no-interaction

Test the results

You can use Postman or curl:

1 2 3 4 5 curl --header "Content-Type: application/json" \ --request POST \ --data '{"email":"user@gmail.com"}' \ http://localhost/api/users

Postman response of calling the /api/users endpoint

Author: Imad Najmi