Восстановлена оплата с иностранных банковских карт. Кнопка оплаты доступна в кабинете >>

Валидация ввода для API

Добавление предварительной валидации данных команд для красивого отображения ошибок в формате JSON для отображения на фронтенде. Централизованный вывод ошибок валидации в middleware.

  • 00:00:32 - Неудобство исключений для валидации
  • 00:02:16 - Постановка задачи
  • 00:03:05 - Формат вывода ошибок
  • 00:04:33 - Как коммитить недоделанные тесты
  • 00:06:05 - Разные способы валидации
  • 00:09:12 - Библиотека Symfony Validator
  • 00:12:25 - Подключение валидатора
  • 00:13:12 - Добавление правил для полей команд
  • 00:14:50 - Исправление загрузки аннотаций
  • 00:16:55 - Вызов валидации в экшенах
  • 00:20:15 - Проблема дублирования кода
  • 00:21:29 - Централизованный отлов ошибок
  • 00:22:16 - Формирование исключения валидации
  • 00:22:53 - Инкапсуляция валидации
  • 00:29:11 - Отлов ошибок в Middleware
  • 00:32:09 - Обзор результата
  • 00:33:19 - Тесты для мультиязычности
Скрытый контент (код, слайды, ...) для подписчиков
Комментарии (29)
Denis

Добрый день, спасибо за урок. А будет как - то реализованы коды ошибок? Чтобы фронт знал как необходимо реагировать на ту или иную ошибку.

Ответить
Дмитрий Елисеев

Пока все ошибки у нас либо доменные, либо валидации. Для кастомного кода можно возвращать напрямую $exception->getCode().

Ответить
Дмитрий

Дмитрий здравствуйте, не могу понять как работает

/**
 * @Assert\NotBlank
 */

Как вообще могут комментарии влиять на код? Здесь получается мы вызвали метод NotBlank? Как это вообще работает?

Ответить
Дмитрий Елисеев

Непосредственно в PHP это просто комментарии и нативно они никак не работают..

Валидатор использует библиотеку doctrine/annotations. А она уже просто обходит поля через рефлексию и парсит эти строки.

Ответить
Arunas

Спасибо. Очень интересно.

Ответить
Роман

Дмитрий, после коммита Extracted validation handling ElisDN, psalm стал ругаться на ошибку ERROR: UndefinedDocblockClass в ValidationExceptionHandler::errorsArray на строку $violation->getMessage(). Решил путём установки полифилла composer require symfony/polyfill-php80 --dev чтобы был доступен \Stringable интерфейс

Ответить
Дмитрий Елисеев

Да, в свежем валидаторе на будущее добавили Stringable, который появится только в PHP 8.0.

Ответить
Евгений

Как поступить в случае, если формат запроса не совпадает с форматом команды? Тогда сообщения об ошибках валидации будут кодироваться неправильными полями. И пользователю будет непонятно к какому полю относится ошибка.

Ответить
Дмитрий Елисеев

В случае если пользователь пришлёт в email вместо строки массив? Тогда да, вылетит ошибка типов. Можно либо добавить приведение (string)($data['email'] ?? ''), либо воспользоваться сериализатором вроде symfony/serializer и отлавливать его исключения.

Ответить
Евгений

Нет. Я имел в виду разные названия полей. У меня недавно был случай когда нужно было к одной команде интегрировать два клиента, но переписать их я не мог. В итоге один присылал email в поле "email", а второй в виде "clientEmail". Если валидацию сделать на уровне команды, то второму клиенту уйдёт сообщение, что email неверный, а он ожидает clientEmail. В итоге я сделал объект формы для валидации ввода с фабричным методом создания команды. Но таким образом оставил команду без валидации. После этого видео задумался, что это не правильно.

Ответить
Евгений

Дмитрий, вы привели пример с валидацией email в объекте Email и поля email в команде. Валидатор email практически стандартный. Но что если нужен кастомный валидатор? Получается его логику придётся дублировать в инициализации объекта/типа и в проверке правильности команды. Подозреваю, что если возникнет ещё один слой с DTO, то придётся и там проверять ещё раз. Например перед записью в хранилище. Но это слабый пример, так как мы доверяем нашей модели.

Ответить
Дмитрий Елисеев

Если сложный кастомный, то да, либо дублировать, либо оставить только в одном месте.

Ответить
Андрей

Добрый день. Заметил один момент - в некоторых фабриках для контейнера Вы используете static function (mailer, errors, http), в других просто function. Выбор того или иного варианта чем-то обусловлен?

Ответить
Дмитрий Елисеев

Здесь не обусловлен. По возможности PhpStorm с плагином EA Extended везде советует объявлять анонимные функции как static function для микрооптимизации.

А в маршрутизации в routes.php не дописываем, так как там фреймворк экшены в виде анонимных функции привязывает к объекту приложения через bind($app), чтобы оно попало к ним в $this. А статические функции нельзя привязать к объекту, поэтому там получим ошибку.

Ответить
Андрей

Спасибо!

Ответить
Владимир

Дмитрий, во всем проекте довольно-таки простые команды, они содержат 1-3 поля. Однако в реальном мире, API может принимать большие структуры данных. Например, могут быть вложенные сущности, массивы вложенных сущностей:

{
  "doctor": {
    "first_name": "",
    "last_name": ""
  },
  "patient": {
    "first_name": "",
    "last_name": ""
  },
  "medications": [
    {
      "name": "",
      "count": ""
    },
    {
      "name": "",
      "count": ""
    }
  ]
}

Как быть в таких ситуациях?

Ответить
Дмитрий Елисеев

Делать классы для вложений:

class Command {
    public Doctor $doctor;
    public Patient $patient;
    public array $medications;
}

class Doctor {
    public string $firstName;
    public string $lastName;
}

class Patient {
    public string $firstName;
    public string $lastName;
}

class Medication {
    public string $name;
    public int $count;
}
Ответить
Владимир Перепеченко

Познакомили с новой технологией, спасибо Дмитрий!

Применяя Symfony Validation + используя рассмотренный подход (Command=DTO), я сталкиваюсь с парой концептуальных проблем. Просьба растолковать, как их избежать.

Проблема первая:

Такой Command после валидации уже нет возможности использовать в качестве полноценной DTO в дальнейшей доменной валидации и дальнейшей обработке.

Почему? Потому что поля объекта Command в нашем сценарии уже не могут быть типизированными, но должны быть mixed, что сильно снижает ценность объекта как DTO и удобство извлечения из него данных.

Почему? Потому что когда мы хотим, например, проверить что в строке реквеста прилетел int (целое число) а не что то иное (float - 123.50) (или наоборот нам нужен float), то мы применяем проверку ПОЛЯ объекта на int или на float:

/**
 * @Assert\Type(type="integer")
 */
public $intProperty

Но чтобы это работало, тип поля должен быть без указания типа (mixed), иначе, если поле будет

public int $intProperty

то Validation будет всегда определять его содержимое как int - всегда будет приведение типа к int при присваивании этому полю строки запроса.

Какие есть варианты сохранить типизированный феншуй?
Делать второй нормальный типизированный DTO на основе Command и отправлять его дальше?
Это весьма костыльно.

Подскажите пожалуйста, Дмитрий: как элегантно пользоваться validation и сохранить удобный типизированный DTO для дальнейшей обработки ?

Ответить
Дмитрий Елисеев

Да, при ручном присваивании есть такая проблема. Вариантов несколько.

Либо валидировать не саму команду, а массив $data = $request->getParsedBody(). И потом уже присваивать провалидированные данные из $data в команду с типами.

Либо в команде оставлять тип mixed, а реальный тип указывать аннотацией:

/**
 * @var int
 * @Assert\Type(type="integer")
 */
public mixed $intProperty;

Либо команду заполнять данными не вручную, а через более умный компонент serializer, который бы видел тип int и выводил красивую ошибку несовпадения типа вместо PHP TypeError.

Ответить
Владимир Перепеченко

А вот вдогонку. Проблема вторая:

В своём проекте, в исключениях (Exception) мне мало одного поля message, поэтому я использую два разных сообщения для одной ошибки - для пользователя и для логов. Типа того:

abstract class BaseException extends Exception
{
    private string $logMessage;

    public function __construct(
        private string $userMessage,    // для блондинок: ваш платеж не прошел - сообшите в поддержку
        ?string $logMessage             // детальное, для гиков в логе: ответ api банка итд
    ) {
        if (!$logMessage) {
            $logMessage=$userMessage;
        }
        $this->userMessage=$userMessage;
        $this->logMessage = $logMessage;

        parent::__construct($logMessage);
    }

    final public function getUserMessage(): string
    {
        return $this->userMessage;
    }

    final public function getLogMessage(): string
    {
        return $this->logMessage;
    }
}

class ValidationException extends BaseException
{
    public function __construct(
        string $userMessage,
        ?string $logMessage=null
    ) {
        parent::__construct($userMessage, $logMessage);
    }
}

и при ловле моего Exeption:

try {
    do something; 
} catch (ValidationException  $exception) {
    ShowUserMessage($exception->getUserMessage);
    $logger->error($exception->getLogMessage);
}

И все было прекрасно пока я не познакомился с Symfony Validation, в котором есть так много вкусных проверок, но предусмотрено только одно сообщение об ошибке(Message).

Дмитрий, как посоветуете использовать Symfony Validation с сохранением двух разных сообщений об одной ошибке?

Благодарю!

Ответить
Дмитрий Елисеев

Можно некрасивые технические ошибки оборачивать красивыми пользовательскими:

try {
    $this->bank->doSmth();
} catch (Exception $e) {
    throw new Exception('Что-то не то с банком!', 0, $e);
}

При этом оригинальное исключение мы передаём в $previous в конструктор внешнего.

Так пользователю выведется внешнее сообщение "что-то не так", а в лог запишется полный стектрейс вместе с вложенным оригинальным исключением для гиков.

Ответить
Владимир Перепеченко

Дмитрий, откройте тайну: почему Вы решили задавать constrains в аннотациях, а не через PHP (ниже пример)? Ведь в аннотация проще ошибиться при множестве правил, а в PHP hinting подсказывает..

use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Mapping\ClassMetadata;

class Author
{
    private $name;

    public static function loadValidatorMetadata(ClassMetadata $metadata)
    {
        $metadata->addPropertyConstraint('name', new NotBlank());
    }
}

Кстати, чтобы включить эту возможность достаточно ли добавить в конфигурации ?

Validation::createValidatorBuilder()->addMethodMapping('loadValidatorMetadata')

Кстати, темп и краткость/подробность подачи материала для меня лично теперь оптимальны, спасибо! А если покажеться что тема не раскрыта - буду вопрошать :)

Ответить
Дмитрий Елисеев

Почему Вы решили задавать constrains в аннотациях

Это визуально удобнее, когда правила находятся рядом с полем, а не где-то отдельно внизу.

Ведь в аннотация проще ошибиться при множестве правил, а в PHP hinting подсказывает.

Уже сейчас в PHP 8 можно вместо аннотаций использовать атрибуты:

class Author
{
    #[Assert\NotBlank]
    public $name;
}

Тогда всё будет нативно подсказываться без плагинов для аннотаций.

Вчера как раз записал скринкаст про перевод нашего проекта на атрибуты. Скоро опубликую.

Ответить
Владимир Перепеченко

Благодарю за вдохновляющие ответы, Дмитрий! Буду использовать.

Вчера как раз записал скринкаст про перевод нашего проекта на атрибуты. Скоро опубликую.

Вот это шикарно! Главное, чтобы чайникам, типа меня, было чётко объяснено преимущество атрибутов перед аннотациями.

Вангую что след каст про переход на PHP 8.1 - там кучу всего задепрекейтили...

Ответить
Дмитрий Елисеев

чётко объяснено преимущество атрибутов перед аннотациями.

Атрибуты - это теперь конструкции языка, где можно вставлять обычный программный код.

А аннотации - это просто обычные текстовые комментарии, которые парсились регулярками.

Ответить
Владимир Перепеченко

Атрибуты - это теперь конструкции языка, где можно вставлять обычный программный код.

осталось понять зачем код вставлять в атрибуты, когда он отлично работает и вне атрибутов :)

Ответить
Дмитрий Елисеев

Не отлично. В комментарии код entity=Token::class не работает.

Ответить
Владимир Перепеченко

entity=Token::class

ну, это и не код :) PHP код был бы таким: $entity=Token::class

В общем, я понял что если Вы в видео не покажете на примерах + с пояснением предназначение атрибутов, то каши в головах может только добавиться

Ответить
Дмитрий Елисеев

Это код с именованными аргументами:

new Embeddable(class: Token::class)
Ответить
Зарегистрируйтесь или войдите чтобы оставить комментарий

Или войти через:

Google
GitHub
Yandex
MailRu