image

Не так давно я написал статью о том, как сделать свой GraphQL сервер на PHP с помощью библиотеки graphql-php и как с его помощью реализовать простое API для получения данных из MySQL.

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

Предисловие


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

Также уточню, что данной статьей я не призываю вас использовать PHP, вместо Node.js или наоборот, а лишь хочу показать как использовать GraphQL, если вы по собственному желанию или же из-за обстоятельств непреодолимой силы работаете с PHP.

Чтобы не объяснять все сначала — за основу я возьму конечный код из предыдущей статьи. Также его можно посмотреть в репозитории статьи на Github. Если вы еще не читали предыдущую статью, то рекомендую ознакомиться с ней прежде чем продолжать.

В данной статье потребуется посылать INSERT и UPDATE запросы к базе данных. Непосредственно к GraphQL это отношения не имеет, поэтому я просто добавлю в уже имеющийся файл DB.php пару новых методов, чтобы не заострять на них внимание в дальнейшем. В итоге код файла будет следующим:

App/DB.php
<?php

namespace App;

use PDO;

class DB
{
    private static $pdo;
    
    public static function init($config)
    {
        // Создаем PDO соединение
        self::$pdo = new PDO("mysql:host={$config['host']};dbname={$config['database']}", $config['username'], $config['password']);
        // Задаем режим выборки по умолчанию
        self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
    }
    
    public static function selectOne($query)
    {
        $records = self::select($query);
        return array_shift($records);
    }
    
    public static function select($query)
    {
        $statement = self::$pdo->query($query);
        return $statement->fetchAll();
    }
    
    public static function affectingStatement($query)
    {
        $statement = self::$pdo->query($query);
        return $statement->rowCount();
    }
    
    public static function update($query)
    {
        $statement = self::$pdo->query($query);
        $statement->execute();
        return $statement->rowCount();
    }
    
    public static function insert($query)
    {
        $statement = self::$pdo->query($query);
        $success = $statement->execute();
        return $success ? self::$pdo->lastInsertId() : null;
    }
}

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

Итак приступим.

Мутации и переменные


Как я уже упоминал в предыдущей статье, схема GraphQL наряду с Query, может содержать еще один корневой тип данных — Mutation.

Этот тип отвечает за изменение данных на сервере. Подобно тому как в REST для получения данных рекомендуется использовать GET запросы, а для изменения данных POST (PUT, DELETE) запросы, в GraphQL для получения данных с сервера следует использовать Query, а для изменения данных — Mutation.

Описание типов полей для Mutation происходит также как и для Query. Мутации как и запросы могут возвращать данные — это удобно если вы например хотите запросить обновленную информацию с сервера сразу же после выполнения мутации.

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

Давайте создадим тип Mutation отдельном файле MutationType.php в папке Type:

App/Type/MutationType.php
<?php

namespace App\Type;

use App\DB;
use App\Types;
use GraphQL\Type\Definition\ObjectType;

class MutationType extends ObjectType
{
    public function __construct()
    {
        $config = [
            'fields' => function() {
                return [
                    // Массив полей пока пуст
                ];
            }
        ];
        parent::__construct($config);
    }
}

И добавим его в наш реестр типов Types.php:

use App\Type\MutationType;

private static $mutation;

public static function mutation()
{
    return self::$mutation ?: (self::$mutation = new MutationType());
}


Осталось добавить только что созданную нами мутацию в схему в файле graphql.php сразу после Query:

$schema = new Schema([
    'query' => Types::query(),
    'mutation' => Types::mutation()
]);

На этом создание мутации завершено, но пока толку от нее мало. Давайте добавим в мутацию поля для изменения данных имеющихся пользователей.

Изменение информации о пользователе


Как мы уже знаем, данные в запрос можно передавать в качестве аргументов. Значит мы можем легко добавить в мутацию поле «changeUserEmail», которое будет принимать 2 аргумента:

  • id — идентификатор пользователя
  • email — новый адрес электронной почты пользователя

Давайте изменим код файла MutationType.php:

App/Type/MutationType.php
<?php

namespace App\Type;

use App\DB;
use App\Types;
use GraphQL\Type\Definition\ObjectType;

class MutationType extends ObjectType
{
    public function __construct()
    {
        $config = [
            'fields' => function() {
                return [
                    'changeUserEmail' => [
                        'type' => Types::user(),
                        'description' => 'Изменение E-mail пользователя',
                        'args' => [
                            'id' => Types::int(),
                            'email' => Types::string()
                        ],
                        'resolve' => function ($root, $args) {
                            // Обновляем email пользователя
                            DB::update("UPDATE users SET email = '{$args['email']}' WHERE id = {$args['id']}");
                            // Запрашиваем и возвращаем "свежие" данные пользователя
                            $user = DB::selectOne("SELECT * from users WHERE id = {$args['id']}");
                            if (is_null($user)) {
                                throw new \Exception('Нет пользователя с таким id');
                            }
                            return $user;
                        }
                    ]
                ];
            }
        ];
        parent::__construct($config);
    }
}

Теперь мы можем выполнить мутацию, которая изменит E-mail пользователя и вернет его данные:

GraphQL запрос на изменение E-mail пользователя

Переменные запроса


Получилось неплохо, но вставлять значения аргументов в текст запроса не всегда удобно.
Чтобы упростить вставку динамических данных в запрос в GraphQL существует специальный словарь переменных Variables.

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

$variables = isset($input['variables']) ? json_decode($input['variables'], true) : null;

И затем передадим их в GraphQL пятым параметром:

$result = GraphQL::execute($schema, $query, null, null, $variables);

После чего код файла graphql.php будет следующим:

graphql.php
<?php

require_once __DIR__ . '/vendor/autoload.php';

use App\DB;
use App\Types;
use GraphQL\GraphQL;
use GraphQL\Schema;

try {
    // Настройки подключения к БД
    $config = [
        'host' => 'localhost',
        'database' => 'gql',
        'username' => 'root',
        'password' => 'root'
    ];

    // Инициализация соединения с БД
    DB::init($config);

    // Получение запроса
    $rawInput = file_get_contents('php://input');
    $input = json_decode($rawInput, true);
    $query = $input['query'];

    // Получение переменных запроса
    $variables = isset($input['variables']) ? json_decode($input['variables'], true) : null;

    // Создание схемы
    $schema = new Schema([
        'query' => Types::query(),
        'mutation' => Types::mutation()
    ]);

    // Выполнение запроса
    $result = GraphQL::execute($schema, $query, null, null, $variables);
} catch (\Exception $e) {
    $result = [
        'error' => [
            'message' => $e->getMessage()
        ]
    ];
}

// Вывод результата
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($result);

Теперь мы можем передать данные в виде JSON (в GraphiQL-расширениях для браузера для этого есть вкладка «Query variables» в нижнем левом углу). А вставить переменные в запрос можно передав их в мутацию подобно тому как передаются аргументы в анонимную функцию (с указанием типа):
mutation($userId: Int, $userEmail: String)

После чего их можно указывать в качестве значений аргументов:
changeUserEmail (id: $userId, email: $userEmail)

И теперь тот же самый запрос будет выглядеть так:

GraphQL запрос на изменение E-mail пользователя с использованием переменных

Добавление нового пользователя


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

В GraphQL типы данных делятся на 2 вида:

  • Output types — типы для вывода данных (или типы полей)
  • Input types — типы для ввода данных (или типы аргументов)

Все простые типы данных (Scalar, Enum, List, NonNull) относятся к обоим видам одновременно.
Такие типы как Interface и Union относятся только к Output, но в данной статье мы их рассматривать не будем.

Составной тип Object, рассмотренный нами в предыдущей части, также относится к Output, а для Input есть аналогичный тип InputObject.

Отличие InputObject от Object состоит в том что его поля не могут иметь аргументов (args) и ресолверов (resolve), а также их типы (type) должны быть вида Input types.

Давайте создадим новый тип InputUserType для доб��вления пользователя. Он будет похож на тип UserType, только мы теперь будем наследовать не от ObjectType, а от InputObjectType:

App/Type/InputUserType.php
<?php

namespace App\Type;

use App\Types;
use GraphQL\Type\Definition\InputObjectType;

class InputUserType extends InputObjectType
{
    public function __construct()
    {
        $config = [
            'description' => 'Добавление пользователя',
            'fields' => function() {
                return [
                    'name' => [
                        'type' => Types::string(),
                        'description' => 'Имя пользователя'
                    ],
                    'email' => [
                        'type' => Types::string(),
                        'description' => 'E-mail пользователя'
                    ]
                ];
            }
        ];
        parent::__construct($config);
    }
}

И не забываем добавить его в наш реестр типов Types.php:

use App\Type\InputUserType;

private static $inputUser;

public static function inputUser()
{
    return self::$inputUser ?: (self::$inputUser = new InputUserType());
}

Отлично! Теперь мы можем использовать его чтобы добавить новое поле «addUser» в MutationType.php рядом с полем «changeUserEmail»:

'addUser' => [
    'type' => Types::user(),
    'description' => 'Добавление пользователя',
    'args' => [
        'user' => Types::inputUser()
    ],
    'resolve' => function ($root, $args) {
        // Добавляем нового пользователя в БД
        $userId = DB::insert("INSERT INTO users (name, email) VALUES ('{$args['user']['name']}', '{$args['user']['email']}')");
        // Возвращаем данные только что созданного пользователя из БД
        return DB::selectOne("SELECT * from users WHERE id = $userId");
    }
]

Обращаю внимание на то что данное поле имеет один аргумент типа InputUser (Types::inputUser()) и возвращает только что созданного пользователя типа User (Types::user()).

Готово. Теперь мы можем добавить нового пользователя в базу данных с помощью мутации. Данные пользователя передаем в Variables и указываем переменной тип InputUser:

GraphQL запрос на добавление пользователя

Валидация и безопасность


Я бы разделил валидацию в GraphQL на 2 вида:

  • Валидация данных передаваемых вместе с запросом (аргументов и переменных)
  • Валидация самих запросов

И хоть в комментариях к предыдущей статье уже не раз говорилось о том, что безопасность вашего приложения находится в ваших руках, а не руках GraphQL, я все же покажу пару простых способов обезопасить свое приложение при использовании graphql-php.

Валидация данных


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

Чтобы обозначить обязательные аргументы будем использовать специальный тип данных NonNull в GraphQL. Давайте подключим его в наш реестр типов:

public static function nonNull($type)
{
    return Type::nonNull($type);
}

Теперь просто обернем им типы тех аргументов, которые являются обязательными.

Будем считать что для пользователя в InputUserType.php поля «name» и «email» обязательны для заполнения:

'fields' => function() {
    return [
        'name' => [
            'type' => Types::nonNull(Types::string()),
            'description' => 'Имя пользователя'
        ],
        'email' => [
            'type' => Types::nonNull(Types::string()),
            'description' => 'E-mail пользователя'
        ],
    ];
}

А для мутации «changeUserEmail» обязательными будут «id» и «email»:

'args' => [
    'id' => Types::nonNull(Types::int()),
    'email' => Types::nonNull(Types::string())
]

Теперь если мы забудем указать какой-либо обязательный параметр, то получим ошибку. Но мы все еще можем указать в качестве E-mail пользователя любую строку. Давайте это исправим.

Для того чтобы мы могли провести проверку полученного E-mail, нам надо создать для него свой скалярный тип данных.

В GraphQL есть несколько встроенных скалярных типов:

  • String
  • Int
  • Float
  • Boolean
  • Id

С некоторыми из них вы уже знакомы, а предназначение остальных очевидно.

Чтобы создать кастомный скалярный тип данных, мы должны напи��ать для него класс, который будет наследовать от ScalarType и реализовывать 3 метода:

  • serialize — сериализация внутреннего представления данных в строку для вывода
  • parseValue — парсинг данных в Variables для внутреннего представления
  • parseLiteral — парсинг данных в тексте запроса для внутреннего представления

Методы parseValue и parseLiteral для скалярных типов во многих случаях будут очень похожи, но стоит обратить внимание на то, что parseValue принимает в качестве аргумента значение переменной, а parseLiteral объект класса Node, содержащий это значение в свойстве «value».

Давайте наконец-то создадим новый скалярный тип данных Email в отдельном файле EmailType.php. Чтобы не хранить все типы в одной большой куче, я помещу этот файл в подпапку «Scalar» папки «Type»:

App/Type/Scalar/EmailType.php
<?php

namespace App\Type\Scalar;

use GraphQL\Type\Definition\ScalarType;

class EmailType extends ScalarType
{
    public function serialize($value)
    {
        return $value;
    }

    public function parseValue($value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \Exception('Не корректный E-mail');
        }
        return $value;
    }

    public function parseLiteral($valueNode)
    {
        if (!filter_var($valueNode->value, FILTER_VALIDATE_EMAIL)) {
            throw new \Exception('Не корректный E-mail');
        }
        return $valueNode->value;
    }
}

Примечание
Саму проверку E-mail на валидность вы можете проводить любым доступным вам способом. В любом фреймворке для этого также есть удобные инструменты.

Остается только добавить очередной тип данных в реестр Types.php:

use App\Type\Scalar\EmailType;

private static $emailType;

public static function email()
{
    return self::$emailType ?: (self::$emailType = new EmailType());
}

И заменить у всех полей «email» тип String (Types::string()) на Email (Types::email()). Например полный код MutationType.php теперь будет таким:

App/Type/MutationType.php
<?php

namespace App\Type;

use App\DB;
use App\Types;
use GraphQL\Type\Definition\ObjectType;

class MutationType extends ObjectType
{
    public function __construct()
    {
        $config = [
            'fields' => function() {
                return [
                    'changeUserEmail' => [
                        'type' => Types::user(),
                        'description' => 'Изменение E-mail пользователя',
                        'args' => [
                            'id' => Types::nonNull(Types::int()),
                            'email' => Types::nonNull(Types::email())
                        ],
                        'resolve' => function ($root, $args) {
                            // Обновляем email пользователя
                            DB::update("UPDATE users SET email = '{$args['email']}' WHERE id = {$args['id']}");
                            // Запрашиваем и возвращаем "свежие" данные пользователя
                            $user = DB::selectOne("SELECT * from users WHERE id = {$args['id']}");
                            if (is_null($user)) {
                                throw new \Exception('Нет пользователя с таким id');
                            }
                            return $user;
                        }
                    ],
                    'addUser' => [
                        'type' => Types::user(),
                        'description' => 'Добавление пользователя',
                        'args' => [
                            'user' => Types::inputUser()
                        ],
                        'resolve' => function ($root, $args) {
                            // Добавляем нового пользователя в БД
                            $userId = DB::insert("INSERT INTO users (name, email) VALUES ('{$args['user']['name']}', '{$args['user']['email']}')");
                            // Возвращаем данные только что созданного пользователя из БД
                            return DB::selectOne("SELECT * from users WHERE id = $userId");
                        }
                    ]
                ];
            }
        ];
        parent::__construct($config);
    }
}

А код InputUserType.php таким:

App/Type/InputUserType.php
<?php

namespace App\Type;

use App\Types;
use GraphQL\Type\Definition\InputObjectType;

class InputUserType extends InputObjectType
{
    public function __construct()
    {
        $config = [
            'description' => 'Добавление пользователя',
            'fields' => function() {
                return [
                    'name' => [
                        'type' => Types::nonNull(Types::string()),
                        'description' => 'Имя пользователя'
                    ],
                    'email' => [
                        'type' => Types::nonNull(Types::email()),
                        'description' => 'E-mail пользователя'
                    ],
                ];
            }
        ];
        parent::__construct($config);
    }
}

И теперь при вводе неправильного E-mail мы увидим ошибку:

Ошибка в GraphQL запросе: некорректный E-mail

Как можно заметить, теперь в запросе для переменной $userEmail мы указываем тип Email, а не String. А также добавляем восклицательные знаки после указания типа всех обязательных аргументов запроса.

Валидация запроса


Для обеспечения безопасности GraphQL производит ряд операций связанных с валидацией полученного запроса. Большинство из них в graphql-php включены по умолчанию и вы уже сталкивались с ними когда видели ошибки при получении ответа от сервера GraphQL, поэтому я не буду разбирать их все а покажу один — наиболее интересный случай.

Зацикливание фрагментов


Когда вы хотите запросить несколько объектов, у которых одинаковые поля, то вы врядли захотите вводить в запросе один и тот же список полей для каждого объекта.

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

По синтаксису фрагменты напоминают оператор spread в JavaScript. Давайте для примера запросим список пользователей и их друзей с некоторой информацией о них.

Без фрагментов это выглядело бы так:

GraphQL запрос списка пользователей и их друзей

А создав фрагмент userFields для типа User, мы можем переписать наш запрос так:

GraphQL запрос списка пользователей и их друзей с использованием фрагментов

Может в этом случае мы не получаем большой выгоды от использования фрагментов, но в более сложном запросе они точно будут полезны.

Но ведь мы сейчас говорим о безопасности. При чем тут вообще какие-то фрагменты?

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

Ошибка в GraphQL запросе: Зацикливание фрагмента

Сложность запроса и глубина запроса


Также, помимо стандартной валидации запроса, graphql-php позволяет задать максимальную сложность и максимальную глубину запроса.

Грубо говоря сложность — это целое число, которое в большинстве случаев соответствует количеству полей в запросе, а глубина — это число уровней вложенности в полях запроса.

По умолчанию максимальная сложность и максимальная глубина запроса равны нулю, то есть не ограничены. Но мы можем ограничить их подключив в graphql.php классы для валидации и соответствующих правил:

use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;

И добавив эти правила в валидатор непосредственно перед выполнением запроса:

// Устанавливаем максимальную сложность запроса равной 6
DocumentValidator::addRule('QueryComplexity', new QueryComplexity(6));
// И максимальную глубину запроса равной 1
DocumentValidator::addRule('QueryDepth', new QueryDepth(1));

В итоге код файла graphql.php должен выглядеть примерно так:

graphql.php
<?php

require_once __DIR__ . '/vendor/autoload.php';

use App\DB;
use App\Types;
use GraphQL\GraphQL;
use GraphQL\Schema;

use GraphQL\Validator\DocumentValidator;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;

try {
    // Настройки подключения к БД
    $config = [
        'host' => 'localhost',
        'database' => 'gql',
        'username' => 'root',
        'password' => 'root'
    ];

    // Инициализация соединения с БД
    DB::init($config);

    // Получение запроса
    $rawInput = file_get_contents('php://input');
    $input = json_decode($rawInput, true);
    $query = $input['query'];

    // Получение переменных запроса
    $variables = isset($input['variables']) ? json_decode($input['variables'], true) : null;

    // Создание схемы
    $schema = new Schema([
        'query' => Types::query(),
        'mutation' => Types::mutation()
    ]);

    // Устанавливаем максимальную сложность запроса равной 6
    DocumentValidator::addRule('QueryComplexity', new QueryComplexity(6));
    // И максимальную глубину запроса равной 1
    DocumentValidator::addRule('QueryDepth', new QueryDepth(1));

    // Выполнение запроса
    $result = GraphQL::execute($schema, $query, null, null, $variables);
} catch (\Exception $e) {
    $result = [
        'error' => [
            'message' => $e->getMessage()
        ]
    ];
}

// Вывод результата
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($result);

Теперь давайте проверим наш сервер. Для начала введем валидный запрос:

Валидный GraphQL запрос

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

Ошибка в GraphQL запросе: Превышение максимальной сложности запроса

И аналогично увеличим глубину запроса:

Ошибка в GraphQL запросе: Превышение максимальной сложности и глубины запроса

Примечание
Если вы используете GraphiQL — расширение для браузера или аналогичный инструмент для тестирования запросов, то стоит помнить, что оно при загрузке посылает запрос на endpoint чтобы узнать какие поля доступны, каких они типов, их описание и т. п.

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

В моем расширении для GrapiQL глубина «системного» запроса равна 7, а сложность 109. Учтите этот ньюанс, дабы избежать непонимания того откуда возникают ошибки.



То есть теперь у вас появляется возможность ограничить нагрузку на сервер и бороться с такой проблемой, как Nested attaсk.

Заключение


Спасибо за внимание.

Задавайте вопросы и я постараюсь на них ответить. А также буду вам благодарен если укажете мне на мои ошибки.

Исходный код с исчерпывающими комментариями также доступен на Github.

Другие части данной статьи:
  1. Установка, схема и запросы
  2. Мутации, переменные, валидация и безопасность
  3. Решение проблемы N+1 запросов