Что такое assert’ы и зачем нужен Symfony-валидатор

Самая рутинная и бОльшая часть работы среднестатистического 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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *