Пишем GraphQL API сервер на Yii2 с клиентом на Polymer + Apollo. Часть 4. Валидация. Выводы

в 22:36, , рубрики: api, graphql, php, yii, yii2

Часть 1. Сервер
Часть 2. Клиент
Часть 3. Мутации
Часть 4. Валидация. Выводы

Валидация и UnionType

Одной из интересных задач с которой пришлось столкнуться была серверная валидация при изменении данных. Как быть, если возникли ошибки при изменении объекта? В статьях можно найти много решений этой проблемы, но мы решили использовать композитный тип Union. Простыми словами, Union — это когда результат запроса может быть не одного лишь типа, а различных, в зависимости от результата выполнения resolve().

Приступим

Для примера добавим в наш объект User (см. Часть 1. Сервер) поле email, и сделаем так чтобы объект невозможно было сохранить с некорректным адресом.

(Напоминаю, что готовый проект к статье можно загрузить с github)

Шаг 1. Добавим поле в БД

Вернемся к серверу из части 1, и добавим нашему юзеру поле email. Для добавления поля в базу данных создадим миграцию:

$> yii migrate/create add_email_to_user

Откроем ее и изменим метод safeUp():

    public function safeUp()
    {
        $this->addColumn('user', 'email', $this->string());
    }

Сохраним и запустим

$> yii migrate

Шаг 2. Добавим правило в объект User

Единственно правильный способ реализации валидации в Yii это переопределением метода Model::rules(). Данный механизм предоставляет широчайшие возможности имплементации сколь угодно кастомизированных валидаций, и ко всем возможностям прилагается подробнейшая документация. Обходить ее стоит лишь в самых редких случаях, в 99% все возможно делать инструментами фреймворка.

/models/User.php:

... 

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return [
            [['id', 'email'], 'required'],
            [['id', 'status'], 'integer'],
            [['createDate', 'modityDate', 'lastVisitDate'], 'safe'],
            [['firstname', 'lastname', 'email'], 'string', 'max' => 45],
            ['email', 'email'],
        ];
    }

...

Таким образом, мы добавили 3 правила в метод rules():

  1. поле не может быть пустым;
  2. поле должно быть строкой;
  3. поле должно содержать валидный email.

Шаг 3. Обновим GraphQL-тип

В schema/UserType.php изменения минимальны:

...
'email' => [
    'type' => Type::string(),
],
...

А вот в мутации начинается самое интересное.

Шаг 4. Добавление ValidationErrorType

Если вы не знакомы с фреймворком Yii, то уточню, что если объект не был сохранен по причине того, что не прошла валидация полей, то мы сможем вытащить все ошибки с помощью метода $object->getErrors(), который возвращает ассоциативный массив в формате:

[
    'названиеПоля' => [
        'Текст первой ошибки',
        'Текст второй ошибки',
        ...
    ],
    ...
]

Формат очень удобный, но не для GraphQL. Дело в том, что выплюнуть непосредственно в JSON как есть мы это не можем по той причине, что ассоциативный массив преобразуется в объект, аттрибуты которого будут полями нашего объекта, а у каждого объекта они свои. А GraphQL, как известно, работает только с предсказуемым результатом. Как вариант, конечно, мы можем создвать отдельный тип для каждого объекта, что-то вроде UserValidationError, у которого все поля будут совпадать с полями самого объекта и содержать Type::listOf(Type::string()). Но ручное создание такого количества типов мне показалось чересчур неоптимальным, и я пошел другим путем.

Добавим единый класс с универсальной структурой schema/ValidationErrorType:

<?php

namespace appschema;

use GraphQLTypeDefinitionObjectType;
use GraphQLTypeDefinitionType;

class ValidationErrorType extends ObjectType
{
    public function __construct()
    {
        $config = [
            'fields' => function() {
                return [
                    'field' => Type::string(),
                    'messages' => Type::listOf(Type::string()),
                ];
            },
        ];

        parent::__construct($config);
    }
}

Данный тип содержит информацию об ошибке валидации для одного поля. Поля cамого типа ValidationErrorType, как по мне, очевидны и содержат название поля, в котором произошла ошибка, и список сообщений с содержанием ошибки. Все предельно просто.

Так как возвращать нам необходимо результат валидации всех полей, а не одного, создадим еще один тип: schema/ValidationErrorsListType:

<?php

namespace appschema;

use GraphQLTypeDefinitionObjectType;
use GraphQLTypeDefinitionType;

class ValidationErrorsListType extends ObjectType
{
    public function __construct()
    {
        $config = [
            'fields' => function() {
                return [
                    'errors' => Type::listOf(Types::validationError()),
                ];
            },
        ];

        parent::__construct($config);
    }
}

Шаг 5. Генерирование UnionType

В GraphQL существует тип Union, это когда результат resolve() поля может возвращать не один лишь тип, а один из нескольких (кажется я это уже писал, но здесь стоило об этом еще раз упомянуть). Таким образом наша цель сейчас состоит в том, чтобы результат мутации изменения/создания объекта, не пройдя или же наоборот, пройдя успешно валидацию возвращал либо сам измененный объект, либо объект типа ValidationErrorsListType.

Что означает "генерирование"? Дело в том, что мы не будем создавать для каждой мутации свой возвращаемый UnionType, а будем его генерировать на ходу, основываясь на базовом типе. Как именно, сейчас покажу.

Изменим наш schema/Types.php:

...

use GraphQLTypeDefinitionUnionType;
use yiibaseModel;

...

private static $validationError;
private static $validationErrorsList;

// здесь будут наши нагенеренные валидирующе типы
private static $valitationTypes;

...
    // c этими двумя всё ясно

    public static function validationError()
    {
        return self::$validationError ?: (self::$validationError = new ValidationErrorType());
    }

    public static function validationErrorsList()
    {
        return self::$validationErrorsList ?: (self::$validationErrorsList = new ValidationErrorsListType());
    }

    // метод возвращает новый сгенерированный тип, на основе
    // типа, который пришел в аргументе
    public static function validationErrorsUnionType(ObjectType $type)
    {
        // перво-наперво мы должны убедиться в том, что генерируем
        // этот тип первый раз, иначе словим ошибку
        // (я уже упоминал ранее о том, что одноименных/одинаковых
        // типов в схеме GraphQL быть не может)
        if (!isset(self::$valitationTypes[$type->name . 'ValidationErrorsType'])) {
            // self::$valitationTypes будет хранить наши типы, чтобы не повторяться
            self::$valitationTypes[$type->name . 'ValidationErrorsType'] = new UnionType([
                // генерируем имя типа
                'name' => $type->name . 'ValidationErrorsType',
                // перечисляем какие типы мы объединяем
                // (фактически мы их не объединяем, а говорим один из каких
                // существующих типом вы будем возвращать)
                'types' => [
                    $type,
                    Types::validationErrorsList(),
                ],
                // в аргументе в resolveType
                // в случае успеха нам придет наш
                // сохраненный/измененный объект,
                // в случае ошибок валидации
                // придет ассоциативный массив из $model->getError()
                // о котором я также упоминал
                'resolveType' => function ($value) use ($type) {
                    if ($value instanceof Model) {
                        // пришел объект
                        return $type;
                    } else {
                        // пришел массив (ну или вообще неизвестно что,
                        // это нас уже мало волнует,
                        // хотя должен массив)
                        return Types::validationErrorsList();
                    }
                }
            ]);
        }

        return self::$valitationTypes[$type->name . 'ValidationErrorsType'];
    }
...

Шаг 6. Мутация

Внесем изменения в UserMutationType:

...

use appschemaTypes;

...

// изменим возвращаемый тип поля update
'type' => Types::validationErrorsUnionType(Types::user()),

...

// добавим еще один аргумент
'email' => Type::string(),

...

'resolve' => function(User $user, $args) {
    // ну а здесь всё проще простого,
    // т.к. библиотека уже все проверила за нас:
    // есть ли у нас юзер, правильные ли у нас
    // аргументы и всё ли пришло, что необходимо
    $user->setAttributes($args);

    if ($user->save()) {
        return $user;
    } else {
        // на практике, этот весь код что ниже -
        // переиспользуемый, и должен быть вынесен
        // в отдельную библиотеку
        foreach ($user->getErrors() as $field => $messages) {
            // поля из ValidationErrorType
            $errors[] = [
                'field' => $field,
                'messages' => $messages,
            ];
        }

        // возвращаемый формат ассоциативного
        // массива должен соответствовать
        // полям типа (в нашем случае ValidationErrorsListType)
        return ['errors' => $errors]; 
    }
}
...

Пришла пора увидеть, как же это работает.

Тестируем

Открываем снова наш GraphiQL и попробуем сделать что-нибудь самое простое.

Жмем в поле ввода Ctrl+Space, и смотрим, что нам может вообще возвращать метод update:

image

Awesome.

Ну и пробуем задать, что нам нужно, и смотрим, что получится.

image

Как видим, сработало первое правило, которое говорит о том, что поле — обязательное.

image

Правило 2 — поле email должно быть таковым в действительности. Валидация сработала.

Ну и посмотрим наконец на успешный результат:

image

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

Выводы

Чтобы понять, из чего именно мы делаем выводы, вам желательно было прочитать все 4 части.

Когда нужно и нужно ли вообще вам использовать GraphQL?

Ну во-первых, нужно понять, стоит ли игра свеч. Если у вас стоит задача написать пару-тройку несложных методов, конечно же, вам GraphQL не нужен. Это будет что-то вроде забивания гвоздей микроскопом. ИМХО, одно из главных преимуществ GraphQL это удобство масштабирования. А возможность масштабирования необходимо закладывать почти в любом проекте (а тем более в том проекте, в котором оно на первый взгляд вовсе не требуется).

Если ваше API "среднестатистическое", то тут, в принципе, разницы между выбранным протоколом вы не почувствуете. Мутации в GraphQL заменяете на экшны для RESTa (час-два вечером после работы под пиво), и оп — у вас уже RESTful API сервер.

Итак...

Очень низкий порог входа. Понять суть языка запросов GraphQL, найти необходимые библиотеки для backend и frontend под любой язык, разобраться как они работают — всё это займет у вас не больше дня (а то и нескольких часов).

Что-то новое. Новый опыт всегда полезен (даже негативный). Как известно, в области веб-технологий, обходя стороной новое, мы, как известно, деградируем.

Использование для внешнего (external) API. Если ваш конечный продукт это и есть API, которым пользуется большое количество клиентов (о которых вы ничего не знаете) GraphQL предоставляет огромную гибкость, так как у этих клиентов могут быть абсолютно разнообразные потребности. Но тут палка о двух концах. Технически, это преимущество, но, к сожалению, клиента может оттолкнуть то, что ему придется изучать что-то новое, и придется искать разработчиков с опытом работы с GraphQL API, ведь не все хотят нанимать разработчиков с обещанием выучить новую технологию в короткие сроки (несмотря на то, но в случае с GraphQL, эти сроки и в самом деле могут быть очень короткие).

Также GraphQL вам поможет в случае удаленной работы, и как следствие, отсутствии тесной коммуникации между backend и frontend разработчиками (ежедневные скайп-созвоны далеко не всегда гарантия "тесных" коммуникаций).

Из недостатков хотелось бы отметить мало примеров в сети по GraphQL + PHP, так как истинные смузихлёбы используют либо Node.js либо Go (что и подвигло меня написать эту серию статей). Та же ситуация с библиотекой Apollo, вся официальная документация по которой написана под React, и мне, как разработчику backend, необходимо было потратить время, чтобы понять, как это всё работает с Polymer, хотя не назвал бы это большой проблемой при переходе. Кстати советую почитать очень содержательный блог Apollo на Medium. Там действительно много интересных и практических статей по GraphQL.

Также одним из недостатков является отсутствие удобного генератора документации, подобного Swagger или ApiDoc.js. Один генератор мне таки найти удалось, но он, к сожалению, весьма убог. Если у вас есть опыт более продвинутого документирования чем описание в pdf, прошу поделиться в комментариях.

Кто уже использует GraphQL из известных компаний?

GitHub. Вцелом ничего удивительного, так как целевая аудитория сайта — разработчики, и никаких переживаний по поводу того, смогут ли их API использовать возникнуть не могло. Стоит отметить что документация выполнена очень красиво и продуманно, и что примечательно, содержит немного информации об основах GraphQL, как его дебажить и гайды по миграции с REST.

Facebook. Являются разработчиком концепции самого GraphQL, и активно его продвигают. В сетях есть много докладов на тему того, как Facebook использует GraphQL.

P.S.: Друзья! Никогда не забывайте имплементировать обработку запросов с методом OPTIONS в своем API, чтобы на все такие запросы сервер всегда возвращал http-код 200, и пустое тело. Это сохранит вам нервные клетки.

И вообще про хедеры для CORS не забывайте при разработке любого API:

<IfModule mod_headers.c>
    Header add Access-Control-Allow-Origin "*"
    Header add Access-Control-Allow-Headers "Content-Type, Authorization"
    Header add Access-Control-Allow-Methods "GET, POST, OPTIONS"
</IfModule>

Автор: timur560

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js