Делаем GraphQL API на PHP и MySQL. Часть 2: Мутации, переменные, валидация и безопасность

в 12:20, , рубрики: graphql, mysql, Nested attaсk, php

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 AppType;

use AppDB;
use AppTypes;
use GraphQLTypeDefinitionObjectType;

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

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

use AppTypeMutationType;

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 AppType;

use AppDB;
use AppTypes;
use GraphQLTypeDefinitionObjectType;

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 AppDB;
use AppTypes;
use GraphQLGraphQL;
use GraphQLSchema;

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 AppType;

use AppTypes;
use GraphQLTypeDefinitionInputObjectType;

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 AppTypeInputUserType;

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 AppTypeScalar;

use GraphQLTypeDefinitionScalarType;

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 AppTypeScalarEmailType;

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 AppType;

use AppDB;
use AppTypes;
use GraphQLTypeDefinitionObjectType;

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 AppType;

use AppTypes;
use GraphQLTypeDefinitionInputObjectType;

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 GraphQLValidatorDocumentValidator;
use GraphQLValidatorRulesQueryComplexity;
use GraphQLValidatorRulesQueryDepth;

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

// Устанавливаем максимальную сложность запроса равной 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 AppDB;
use AppTypes;
use GraphQLGraphQL;
use GraphQLSchema;

use GraphQLValidatorDocumentValidator;
use GraphQLValidatorRulesQueryComplexity;
use GraphQLValidatorRulesQueryDepth;

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.

Автор: XAHTEP26

Источник

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


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