Часть 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 app\schema;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

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 app\schema;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

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 GraphQL\Type\Definition\UnionType;
use yii\base\Model;

...

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 app\schema\Types;

...

// изменим возвращаемый тип поля 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>

Комментарии (25)


  1. napa3um
    06.09.2017 08:04

    Оффтоп
    «Мутации в GraphQL заменяете на экшны для RESTa» — опять путают REST и RPC. В REST строго ограниченное количество «экшнов», они не пользовательские, это — глаголы HTTP. Прямых аналогов мутаций GraphQL или экшнов RPC в REST нет, выходящие за рамки CRUD операции прикладной области должны быть выражены отдельными ресурсами-существительными (например, «квитанциями об операции» или «приказами»). Именно ограниченность количества экшнов над ресурсами и фиксированность их семантики и помогает держать архитектуру приложения в чистоте и не стрелять себе творчески в ногу.


    1. VolCh
      06.09.2017 09:02

      Опять путают REST-архитектуру и HTTP в качестве протокола прикладного уровня. Система может использовать HTTP полностью забивая на его семантику, но пои этом не переставпть быть REST


      1. napa3um
        06.09.2017 09:11

        И зачем же вы путаете REST и HTTP? Чтобы оставаться в рамках архитектурных ограничений REST, но забивать на HTTP, придётся переизобрести собственный ограниченный набор CRUD-методов над ресурсами. Суть претензии от этого не меняется, «пользовательских» мутаций и экшнов в REST нет, есть только ограниченный набор операций над любыми ресурсами. Претензия вашим замечанием не устраняется: мутации GraphQL — это аналог экшнов со свободной семантикой из подхода RPC, но совсем не аналог строго ограниченных операций над ресурсами из REST.


        1. VolCh
          06.09.2017 20:08

          Нет фундаментальных ограничений на количество оепраций в REST. Более того, сервер сам (может/должен?) сообщать о доступных операциях касающихся конкретного ресурса, отдавая клиенту его представление вместе с метаданными соотвествующими (единого стандрта описания нет) операции, например url и метод, которыми нужно вызвать сервер, чтобы изменить ресурс. Есть общепринятые и частично специфицированные принципы построения CRUD операций по HTTP, но REST не накладывает ограничений на общее количество или строгое соблюдение семантики HTTP. Более того REST не требует HTTP в принципе. Просто самый распространенный протокол для создания REST-подобных серверных API. "Подобных" потому что не встречал в реальной жизни полноценных реализаций REST через HTTP — те или иные компромиссы между практичностью и пуризм всегда есть.


          1. napa3um
            06.09.2017 20:15

            Вы явно не в курсе, о чём REST, зачем в нём ограничения (и весь тот ворох RFC об ошибках и правилах обработки операций над ресурсами). Попробуйте начать с раскрытия аббревиатуры, уже в ней что-то должно вас натолкнуть на мысль об ограниченности глаголов для работы с ресурсами. Клиент с сервером обменивается только изменениями репрезентаций ресурсов по унифицированному протоколу, независимому от семантики ресурсов в прикладной задаче.


            1. VolCh
              08.09.2017 12:35

              Какие ещё RFC по REST? Вы не путаете с RFC по HTTP? А сам REST не путаете с типичными (стандарта нет) HTTP RESTful API? Какие принципы REST запрещают мне создать свой унифицированный прикладной протокол для своего API, используя HTTP лишь как транспортный, с минимальной оглядкой на его семантику?


              1. napa3um
                09.09.2017 06:53

                Вы просто несёте бред, перестаньте. Изучите, что такое REST и зачем он, тогда поймёте, насколько глупо звучит ваше «без оглядки на его семантику». Вы дистанцировали REST от его реализации в виде HTTP, избавили от его семантики, что же вы вообще под ним подразумеваете? (Это риторический вопрос.)


  1. rraderio
    06.09.2017 09:51

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

    А можно как-то в GraphiQL подсказать что поле не может быть пустым?


    1. timur560 Автор
      06.09.2017 11:26

      Конечно. Для этого нужно в схеме в аргументе прописать обертку:


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

      Так сработает валидация уже на уровне схемы, т.е. не доходя до модели, и сам GraphiQL это уже будет знать, и подчеркивать красным.


      1. rraderio
        06.09.2017 11:58

        Ну, не совсем, надо указать что строка не может быть пустой а не null. Другой пример для чисел, указать что цена например должна быть больше нуля?


        1. timur560 Автор
          06.09.2017 12:08

          Type::nonNull() указывает на то, что этот параметр обязательно должен быть передан. Честно говоря с настолько кастомной валидацией, о которой вы говорите, средствами самого GraphQL не сталкивался.

          Насколько мне известно, валидация на уровне GraphQL ограничивается корректностью самого запроса (правильные названия полей и структура); обазательное/необязательное поле; и правильный ли тип (Int, String, ...). Такие валидации GraphiQL покажет. Более серьезные валидации с проверкой, например, уникальности поля в БД, и т.п. не касаются самой схемы, потому никак не могут быть реализованы на ее уровне, потому GraphiQL о них ничего не узнает.


          1. rraderio
            06.09.2017 12:14

            Может быть можно написать комментарий, а GraphiQL его покажет?


            1. timur560 Автор
              06.09.2017 12:20

              Ну как вариант, да. Но это не валидация, а лишь нотификация.


              Для этого нужно использовать InputTypes. Я, честно говоря, с ними еще не работал, но пора начинать. Они впринципе как улучшают архитектуру так и расширяют возможности.


              1. rraderio
                06.09.2017 12:34

                Будет супер если в следующей статье добавите примеры.


                1. timur560 Автор
                  06.09.2017 12:39

                  Взято на заметку


        1. timur560 Автор
          06.09.2017 12:11

          Так же хочу немного поправить, то о чем я написал. Обязательность аргумента и обязательность поля на уровне ActiveRecord будет работать по разному, т.к. если поле не передано, но оно и так уже не пустое в редактируемой модели, все пройдет ОК. А при обязательности в схеме наше поле будет запрашиваться в любом случае.


  1. vtvz_ru
    06.09.2017 11:20

    Хорошая серия статей. Хочу ещё) Было бы здорово, если были раскрыты темы аутентификации, пагинации и безопасности


    1. timur560 Автор
      06.09.2017 11:29

      Спасибо! Значит есть над чем работать. Задача пагинации уже стоит на будущее, с авторизацией на самом деле все просто, там для статьи мало информации, хотя я находил статьи по авторизации в GraphQL, нужно почитать, о чем именно там пишут. Насчет безопасности, не знаю что может быть специфичного именно касательно GraphQL.


      1. rraderio
        06.09.2017 12:08

        +1. Ждем про аутентификацию и пагинацию.


      1. vtvz_ru
        06.09.2017 12:44

        Ну да, аутентифицироваться можно через токен. Но вот разграничение прав на доступ к запросам и мутациям. Особенно интересует, как решается такая проблема:


        У разработчика Х есть возможность посмотреть основные данные профиля пользователя. У пользователя есть список авто, который доступен через поле "auto". И у разработчика Х нет никаких прав на просмотр автомобилей. Как это решается? Хорошо, тут мы можем просто выдать пустой массив с авто. Но что делать, если у Х нет прав на просмотр некоторых полей профиля? Например email, который доступен только избранным? Оставлять поле пустым при запросе (как это фильтровать)? Как тогда защитить поля от мутаций? Писать отдельные мутации для каждого поля отдельно (updateUserEmail, updateUserPhone)?


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


        В плане безопасности: глубокие, рекурсивные запросы, которые могут сильно нагрузить сервер (user->friends->friends->friends->friends)


        В плане производительности: проблемы характера N+1


        Ну и конечно же пагинация и сортировка: если у меня в ВК 500 друзей, но мне нужны только 6 случайных для отображения в левом столбике с друзьями, а не все (как пример)


        И еще: есть ли в GraphQL какой-нибудь простой метод создания CRUD структуры, которая бы легко меняла REST схему. Пока что я не вижу простого метода, кроме как создания трех мутаций для каждого отдельного случая (create, update, delete).


        Возможно ли как-то объединить схему GraphQL и класс Yii2, чтобы схема содержалась внутри модели AR или даже вытягивалась из нее?


        1. timur560 Автор
          06.09.2017 12:58

          Касательно вопроса с доступом, это классическая система ролей. Фреймворк всегда знает, аутентифицированного юзера, и всю информацию можно вытащить в любом месте. Как вариант, мы можем в зависимости от роли (или по другому условию) генерировать разную схему.


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

          Не очень понял о чем вы. Все, чего не может GraphQL, может фреймворк, а то, что может фреймоворк, можно завернуть в средства GraphQL. Именно это я показал на примере того, как реализовал валидацию.


          Ну и конечно же пагинация и сортировка: если у меня в ВК 500 друзей, но мне нужны только 6 случайных для отображения в левом столбике с друзьями, а не все (как пример)

          Первое, что приходит в голову, это банально добавить параметры page — номер страницы, offset смещение (или cpp — count per page) — классические параметры для паджинации, которые мы потом используем в выборке из БД. Возможно есть и другие способы.


          И еще: есть ли в GraphQL какой-нибудь простой метод создания CRUD структуры, которая бы легко меняла REST схему. Пока что я не вижу простого метода, кроме как создания трех мутаций для каждого отдельного случая (create, update, delete).

          Я пока тоже ничего проще не вижу, но это как раз решается тем, о чем вы написали:


          Возможно ли как-то объединить схему GraphQL и класс Yii2, чтобы схема содержалась внутри модели AR или даже вытягивалась из нее?

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


        1. VolCh
          06.09.2017 20:14

          Но что делать, если у Х нет прав на просмотр некоторых полей профиля? Например email, который доступен только избранным? Оставлять поле пустым при запросе (как это фильтровать)? Как тогда защитить поля от мутаций? Писать отдельные мутации для каждого поля отдельно (updateUserEmail, updateUserPhone)?

          Отдавать "403" при попытке получить недоступные поля-записи, или давать специальные значения (пустые или что-то вроде "доступ запрещен"). Отдавать 403 при попытках мутировать недоступные поля или игнорировать.


      1. VolCh
        06.09.2017 20:10

        Насчет безопасности, не знаю что может быть специфичного именно касательно GraphQL.

        Загрузить сервер большой вложенностью запроса.


  1. MOTORIST
    06.09.2017 17:13

    Как в GraphQL различаются типы ошибок? Если при обновлении записи ошибка возникла не в валидации, а в правах доступа?

    На клиенте при ошибке валидации мне нужно подсветить поля формы, а при нехватке прав доступа показать сообщение (toast).


    1. timur560 Автор
      06.09.2017 17:26

      Можете сделать аналогичный тип валидации PermissionErrorType, тогда ваша мутация будет выглядеть примерно таким образом:


      ...
      update(...) {
        ...on PermissionError {
          errorText
        }
        ...on ValidationErrorsList {
          ...
        }
        ...on User {
          firstname
        }
      }
      

      ну а в resolve() вы должны на нехватку пермишнов выплюнуть соостветственный формат. Другой вариант это вернуть просто 401, который для этого и предназначен.