
Самая рутинная и бОльшая часть работы среднестатистического backend-разработчика — написание API, которое потом будут дёргать клиенты. Если, конечно, мы говорим о современной разработке, где front и back пишутся отдельно. Клиентской частью может быть как браузерный фронт — React, Vue, Angular, … ; так и Android, iOS, … — приложения. О том, как правильно писать API — написано много, а сказано ещё больше. Поэтому сегодня я не буду говорить о идеальном REST ̶н̶а̶ ̶с̶а̶м̶о̶м̶ ̶д̶е̶л̶е̶ ̶б̶у̶д̶у̶, о GraphQL (потому что с ним ещё не работал 🙂 ) и о других умных вещах. Сегодня мы поговорим о крайне полезной и простой штуке, которая очень упростит тебе в дальнейшем жизнь и сэкономит много как времени, так и нервов — стандартные Symfony — правила, которые описываются напрямую в сущности и затем проверяются валидатором. Лично я этого, когда устраивался на первую работу, — не знал и это стало для меня приятным открытием. А чтобы было веселей и понятней, мы напишем небольшое приложение, которое будет отдавать и валидировать данные для (та-дааа) обычного todo-листа. Да, ничего лучше я не придумал, потому что это самый простой и наглядный пример.
Я надеюсь, что ты читал мои статьи про запросы, основы работы с Symfony и краткое описание MVC (если нет — ссылки можно найти на канале). Поэтому сегодня мы не будем останавливаться на основах и перейдём сразу к делу.
Первым делом определимся, над какой сущностью будем строить наши запросы. Сущность у нас, на данной итерации, одна — Task
, т.е. задача. У нас todo-лист, куда мы будем добавлять задачи. Что можно сделать с задачей? Её можно создать. Раз можно создать — можно и получить из базы. Можно отредактировать — поменять статус (в бэклоге, в процессе, завершена, на тестировании), изменить название, описание или время дедлайна. Ещё её можно удалить.
Получается, у нас есть 4 атомарных операции, а значит — будут 4 контроллера. Теперь нужно грамотно подобрать типы HTTP
-запросов. Для создания используем POST
, так как именно пост служит для передачи новых данных на сервер. Для получения данных по id
используется GET
-запрос. Для редактирования данных — используем PUT
, так как именно этот тип запроса предназначен для обновления уже существующих данных. Для удаления — DELETE
. Я обещал не затрагивать философию REST
, но, к слову, мы только «спроектировали» каноничную REST
-апиху.
Теперь к коду. Создаём новый проект:
composer create-project symfony/skeleton todo-list-api
cd todo-list-api
composer require symfony/web-server-bundle --dev
Теперь ставим зависимости для ORM и для простого создания различных сущностей:
composer require symfony/orm-pack
composer require --dev symfony/maker-bundle
В качестве БД — mysql, как с ним работать и что такое ORM — писал здесь. Напомню, что чтобы сконфигурировать mysql с нуля — нужно выполнить аналогичные шаги. У меня mysql установлен и запущен, поэтому я просто создаю mysql-пользователя и базу:
sudo mysql -u root
CREATE DATABASE todo;
CREATE USER 'todo-user'@'localhost' IDENTIFIED BY 'todo-password';
GRANT ALL PRIVILEGES ON todo.* TO 'todo-user'@'localhost';
FLUSH PRIVILEGES;
quit;
Теперь в .env прописываем данные для подключения:
DATABASE_URL=mysql://todo-user:todo-password@127.0.0.1:3306/todo
И проверяем, что подключение корректно:

Теперь наконец-то подготовка закончена и мы можем создать сущность Task
. Как я сказал выше, логично, если у неё будут минимум 4 поля — название, описание, время и статус. Создаём:
bin/console make:entity
Class name of the entity to create or update (e.g. GentleJellybean):
> Task
created: src/Entity/Task.php
created: src/Repository/TaskRepository.php
Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.
New property name (press <return> to stop adding fields):
> name
Field type (enter ? to see all types) [string]:
>
Field length [255]:
>
Can this field be null in the database (nullable) (yes/no) [no]:
> no
updated: src/Entity/Task.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> description
Field type (enter ? to see all types) [string]:
> text
Can this field be null in the database (nullable) (yes/no) [no]:
> yes
updated: src/Entity/Task.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> status
Field type (enter ? to see all types) [string]:
>
Field length [255]:
>
Can this field be null in the database (nullable) (yes/no) [no]:
> no
updated: src/Entity/Task.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> deadline
Field type (enter ? to see all types) [string]:
> datetime
Can this field be null in the database (nullable) (yes/no) [no]:
> no
updated: src/Entity/Task.php
Add another property? Enter the property name (or press <return> to stop adding fields):
>
Success!
Теперь создаём миграцию и сразу её накатываем, чтобы нашу программную сущность мигрировать в схему данных:
bin/console make:migration
bin/console d:m:m
По-итогу, получается такая таблица:

Теперь переходим к контроллерам. Создаём класс:
bin/console make:controller
Создание контроллер для создания задачи и параллельно добавляем некоторые зависимости в класс:
<?php
namespace App\Controller;
use App\Entity\Task;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class TaskController extends AbstractController
{
private const STATUS_BACKLOG = 'backlog';
private const STATUS_ACTIVE = 'active';
private const STATUS_DONE = 'done';
private const STATUS_TEST = 'test';
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @Route("/task", name="task", methods="POST")
* @param Request $request
* @return JsonResponse
* @throws Exception
*/
public function createTask(Request $request)
{
$taskData = json_decode($request->getContent(), true);
$task = new Task();
$task->setName($taskData['name'])
->setDescription($taskData['description'])
->setStatus($taskData['status'])
->setDeadline(new DateTimeImmutable($taskData['deadline']));
$this->entityManager->persist($task);
$this->entityManager->flush();
return $this->json([
'status' => 'success',
]);
}
}
На что тут стоит обратить внимание. Во-первых, мы используем Symfony-autowire, который автоматически распознаёт, какой класс реализует интерфейс EntityManagerInterface и автоматически его загружает. Интерфейс этот передаётся как инъекция зависимости в конструктор. Почему в конструктор, а не напрямую в контроллер? Потому что все контроллеры работают с EntityManager’ом и логичней его вынести туда. Если бы мы использовали какой-либо интерфейс, с которым работает только определённый контроллер — то передали бы его как аргумент контроллера, как советует делать документация по Symfony.
Во-вторых, PHP 7 позволяет делать приватные константы, что есть хорошо. Заюзали.
В-третьих, просто для общего развития — все автоматически сгенерированные сетеры в сущностях возвращают ссылку на экземпляр объекта сущности, т.е. ссылку на $this. Это нужно для того, чтобы не писать код в таком виде:
$task->setName($taskData['name']);
$task->setDescription($taskData['description']);
А писать в таком:
$task->setName($taskData['name'])
->setDescription($taskData['description']);
Такой паттерн называется «Строитель», или «Builder».
Ну и главное — этот код крайне не безопасен. Чтобы понять почему, нам, по-хорошему, нужно обложить его функциональными тестами и набросать кучу утверждений, но, пока никто не видит, мы сделаем проще — откроем Postman (установить можно через менеджер приложений Ubuntu как span-пакет или с официального сайта, если ты зачем-то сидишь под Windows) и попробуем ударить на наш контроллер, запустив перед этим dev-сервер:

И сам запрос:

Произошла ошибка, потому что поле deadline объявлено как обязательное и мы должны его передать. По-идее, первичную валидацию должен делать frontend-разработчик или разработчик клиента, который принимает пользовательские данные. Но никому нельзя верить и лучше перестраховаться, чтобы потом не ловить алерты с сервера и не работать на выходных. Что первое приходит в голову? Написать кучу условий. Что же, как вариант:
/**
* @Route("/task", name="task", methods="POST")
* @param Request $request
* @return JsonResponse
* @throws Exception
*/
public function createTask(Request $request)
{
$taskData = json_decode($request->getContent(), true);
$task = new Task();
if (isset($taskData['name']) && !empty($taskData['name'])) {
$task->setName($taskData['name']);
} else {
return $this->json([
'status' => 'error',
]);
}
if (isset($taskData['status']) && !empty($taskData['status'])) {
$task->setStatus($taskData['status']);
} else {
return $this->json([
'status' => 'error',
]);
}
if (isset($taskData['deadline']) && !empty($taskData['deadline'])) {
$task->setDeadline(new DateTimeImmutable($taskData['deadline']));
} else {
return $this->json([
'status' => 'error',
]);
}
if (isset($taskData['description']) && !empty($taskData['description'])) {
$task->setDescription($taskData['description']);
}
$this->entityManager->persist($task);
$this->entityManager->flush();
return $this->json([
'status' => 'success',
]);
}
Комбинируй, оптимизируй, вводи тернарные операторы — в любом случае код выглядит паршиво. Но, теперь мы валидируем входные данные. Конечно, логику валидации можно вынести в какой-то сервис, сделать у него красивый интерфейс и код станет почище, но всё уже придумали за нас.
Знакомься — Symfony Validator. Я надеюсь, что ты читал мою предыдущую статью про тесты и уже знаешь что такое assert-ы. Так вот в Symfony есть такой же тип аннотаций, где можно описывать какие-либо утверждения, прямо в классе сущности. Сначала установим пакеты:
composer require symfony/validator doctrine/annotations
И краем глаза глядя в документацию, давай опишем такие правила для нашей сущности. Кстати, валидатор применяется в Symfony-формах по-дефолту. Но, так как у нас front подразумевается отдельным приложением, мы будем юзать его отдельно. Первое правило, которое нужно будет описать у полей name, status и deadline — NotBlank, которое говорит о том, что поле не может быть пустым (пустая строка, пустой массив или null не прокатят). Получается как-то так:
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank(message="Значение поля `name` не передано")
*/
private $name;
/**
* @ORM\Column(type="text", nullable=true)
*/
private $description;
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank(message="Значение поля `status` не передано")
*/
private $status;
/**
* @ORM\Column(type="datetime")
* @Assert\NotBlank(message="Значение поля `deadline` не передано")
*/
private $deadline;
Теперь в контроллер передадим интерфейс валидатора и провалидируем сущность перед сохранением, избавившись от кучи условий. Единственное условие, которое придётся оставить — проверку индексов в массиве, но это мелочи, потому что мы смогли отказаться от проверки всей кастомной логики, которой в больших сущностях обычно очень много.
/**
* @Route("/task", name="task", methods="POST")
* @param Request $request
* @param ValidatorInterface $validator
* @return JsonResponse
* @throws Exception
*/
public function createTask(Request $request, ValidatorInterface $validator)
{
$taskData = json_decode($request->getContent(), true);
$task = new Task();
if (!isset($taskData['name'], $taskData['description'], $taskData['deadline'])) {
return $this->json([
'success' => false,
'errors' => 'Переданы не все поля'
]);
}
$task->setName($taskData['name'])
->setDescription($taskData['description'])
->setStatus($taskData['status'])
->setDeadline(new DateTimeImmutable($taskData['deadline']));
$errors = $validator->validate($task);
if (count($errors) > 0) {
return $this->json([
'success' => false,
]);
}
$this->entityManager->persist($task);
$this->entityManager->flush();
return $this->json([
'success' => true,
]);
}
К — красиво.
Проверяем:



Зачем всё это ради одной проверки? Это — наш тестовый пример, где валидируется мало данных. NotBlank — самая частая и нужная проверка, так как клиенты очень часть пропускают какие-то поля, фронт пропускает их валидацию и это улетает в контроллер. Помимо NotBlank, в дальнейшем, мы разберём множество других утверждений, которые помогают валидировать данные и не переусложняют код.
Тем временем я дописал методы для получения, обновления и удаления задачи и залил всё это на bitbucket — ссылка ниже.
Спасибо за внимание 🙂
Код проекта — https://bitbucket.org/junsenior/todo-list-api/src/master/
Twitter — https://twitter.com/SeniorJun — анонсы, мысли и обсуждения. Тут обычно то, что в канал я не рискну писать 🙂
Поддержать развитие канала — https://www.patreon.com/junsenior