Часть 1. Сервер
Часть 2. Клиент
Часть 3. Мутации
Часть 4. Валидация. Выводы
Порой при разработке API случается так, что необходимо не только лишь получать данные, но и вносить определенные изменения. Именно для этой цели существует то, что в GraphQL называется странным словом "мутация".
Сервер
Вдоволь наигравшись с клиентской частью, вернемся таки к нашему серверу и добавим несколько мутаций. Для мутаций нам необходимо иметь отдельную от query точку входа (MutationType), а сам функционал реализуется через параметры полей args и resolve.
Вопрос: Могу ли я реализовать мутации через поля секции query? Хороший вопрос. Дело в том, что гипотетически это возможно, но архитектурно неправильно. А еще библиотека Apollo любит делать корневой запрос, т.е. имея всю структуру, запрашивает все, что возможно. Зачем она это делает, я не знаю, но предположительно, если засунуть в query методы вроде delete(), можете случайно лишиться ценного.
Шаг 1. Создадим необходимые типы
/schema/mutations/UserMutationType.php:
<?php namespace app\schema\mutations; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use app\models\User; class UserMutationType extends ObjectType { public function __construct() { $config = [ 'fields' => function() { return [ // для теста реализуем здесь // один метод для изменения данных // объекта User 'update' => [ // какой должен быть возвращаемый тип // здесь 2 варианта - либо // булев - удача / неудача // либо же сам объект типа User. // позже мы поговорим о валидации // тогда всё станет яснее, а пока // оставим булев для простоты 'type' => Type::boolean(), 'description' => 'Update user data.', 'args' => [ // сюда засунем все то, что // разрешаем изменять у User. // в примере оставим все поля необязательными // но просто если нужно, то можно 'firstname' => Type::string(), 'lastname' => Type::string(), 'status' => Type::int(), ], 'resolve' => function(User $user, $args) { // ну а здесь всё проще простого, // т.к. библиотека уже все проверила за нас: // есть ли у нас юзер, правильные ли у нас // аргументы и всё ли пришло, что необходимо $user->setAttributes($args); return $user->save(); } ], ]; } ]; parent::__construct($config); } }
Совет. Старайтесь делать ваши функции resolve() как можно менее нагруженными. Как видите, GraphQL позволяет это сделать максимально. Переносите максимально всю логику в модели. Схема и API это лишь связующее звено между клиентом и сервером. Этот принцип касается не только GraphQL, а и любой серверной архитектуры.
Аналогично /schema/mutations/AddressMutationType.php:
<?php namespace app\schema\mutations; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use app\models\Address; use app\schema\Types; class AddressMutationType extends ObjectType { public function __construct() { $config = [ 'fields' => function() { return [ 'update' => [ 'type' => Type::boolean(), 'description' => 'Update address.', 'args' => [ 'street' => Type::string(), 'zip' => Type::string(), 'status' => Type::int(), ], 'resolve' => function(Address $address, $args) { $address->setAttributes($args); return $address->save(); }, ], // так как у нас адрес имеет поле // user, то можем позволить редактировать // его прямо отсюда // как именно, посмотрим на этапе тестирования 'user' => [ 'type' => Types::userMutation(), 'description' => 'Edit user directly from his address', // а вот поле relove должно возвращать // что, как думаете? 'resolve' => function(Address $address) { // именно! // юзера из связки нашего адреса // (кстати, если связка окажется пуста - // не страшно, GraphQL, все это корректно // кушает, а вот если она окажется типа // отличного от User, тогда он скажет, что мол // что-то пошло не так) return $address->user; } ], ]; } ]; parent::__construct($config); } }
Во время разработки своего сервера, внимательно следите за тем, чтоб не использовать мутацию для выборки, или query для изменения данных, потому что жесткой привязки, как таковой нет.
Ну и корневой тип: /schema/MutationType.php:
<?php namespace app\schema; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use app\models\User; use app\models\Address; class MutationType extends ObjectType { public function __construct() { $config = [ 'fields' => function() { return [ 'user' => [ 'type' => Types::userMutation(), 'args' => [ 'id' => Type::nonNull(Type::int()), ], 'resolve' => function($root, $args) { return User::find()->where($args)->one(); }, ], 'address' => [ 'type' => Types::addressMutation(), 'args' => [ 'id' => Type::nonNull(Type::int()), ], 'resolve' => function($root, $args) { return Address::find()->where($args)->one(); }, ], ]; } ]; parent::__construct($config); } }
Шаг 2. Добавим созданные типы Types.php
Если вы заметили, на прошлом шаге мы уже использовали кастомные типы из Types, хотя еще и не создали их. Этим собственно сейчас и займемся.
... // т.к. наши мутации в другом неймспейсе // необходимо их подключить use app\schema\mutations\UserMutationType; use app\schema\mutations\AddressMutationType; ... private static $userMutation; private static $addressMutation; ... public static function mutation() { return self::$mutation ?: (self::$mutation = new MutationType()); } public static function userMutation() { return self::$userMutation ?: (self::$userMutation = new UserMutationType()); } public static function addressMutation() { return self::$addressMutation ?: (self::$addressMutation = new AddressMutationType()); } ...
Шаг 3. Добавим корневой тип в точку входа GraphqlController.php
... $schema = new Schema([ 'query' => Types::query(), 'mutation' => Types::mutation(), ]); ...
Шаг 4. Тестируем
Откроем же наш GraphiQL (а в соседней вкладке наш новосозданный клиент, чтобы убедиться, что данные таки меняются) и посмотрим на результат:
Запрос:
mutation { user(id:1) { update(firstname:"Stan") } }


Теперь попробуем изменить адрес и привязанного к нему юзера одним запросом:
Запрос:
mutation { address(id:0) { update(zip: "56844") user { update(firstname:"Michael") } } }

Чтобы увидеть изменения адреса, немного изменим наш шаблон:

Сразу попытаемся представить и сравнить с тем, как нужно изощриться, чтоб провернуть что-то подобное в RESTful архитектуре. А вообще, подобные вещи, насколько мне известно, перечат концепции REST-а, а в GraphQL это изначально заложено архитектурно.
Переменные
Пока мы не перешли к клиенту разберемся что такое variables в GraphQL. С практическим их применением вы познакомитесь при использовании в мутациях в клиенте, а пока не заморачивайтесь над этим, т.к. изначально их польза не так заметна.
Изменим немного нашу мутацию с использованием переменных:
Запрос:
mutation ($id:Int, $zip: String, $firstname: String) { address(id: $id) { update(zip: $zip) user { update(firstname: $firstname) } } }
Переменные:
{ "id": 1, "zip": "87444", "firstname": "Steve" }
Примечание. Технически, переменные приходят отдельным POST-параметром variables.
Окно GraphiQL (поле для ввода переменных нужно просто вытянуть снизу, да, оно у вас тоже есть):

Может показаться, что переменные удобны, когда в нескольких местах используется одно и то же значение, но на самом деле, на практике это редкая ситуация, и нет, это не основное их предназначение.
Более полезным является возможность сразу же произвести валидацию поля. Если попытаться в переменную передать неверный тип и/или если вовсе не передать, в случае когда поле обязательное, запрос на сервер не уйдет.
Но основное удобство (я бы даже сказал необходимость) использования вы ощутите в клиенте.
Клиент
Шаг 1. Добавим мутацию в models/user.js
Как вы помните, все наши GraphQL-запросы мы договорились хранить в models (не почти все, а все-все), посему добавим нашу новую мутацию.
models/user.js:
... // не забываем присваивать алиасы // мутациям они тоже необходимы export const updateAddressAndUserMutation = gql` mutation updateAddressAndUser( $id: Int!, $zip: String, $street: String, $firstname: String, $lastname: String ) { address(id: $id) { update(zip: $zip, street: $street) user { update( firstname: $firstname, lastname: $lastname ) } } } `;
Шаг 2. Компонент
Чтобы было интереснее, создадим новый компонент, заодно посмотрим как работаем механизм событий для общения между компонентами (никакого отношения к GraphQL, поэтому без энтузиазма).
Создаем директорию /src/update-user-address и ложим туда традиционно 2 файла: update-user-address.html и update-user-address.js.
Примечание. Если хотите назвать свой компонент как-нибудь по-другому, имейте ввиду, что существует неочевидное требование к именованию. Дело в том, что кастомный компонент в имени должен обязательно содержать "-". Вот так.
/src/update-user-address/update-user-address.js:
import { PolymerApolloMixin } from 'polymer-apollo'; import { apolloClient } from '../client'; // не забываем заимпортить все необходимые запросы import { getUserInfoQuery, updateAddressAndUserMutation } from '../models/user'; class UpdateAddressUser extends PolymerApolloMixin({ apolloClient }, Polymer.Element) { static get is() { return 'update-address-user'; } static get properties() { return { user: { type: Object, value: {}, // observer это метод // что будет вызываться при изменении // свойства // зачем это нужно читаем ниже observer: "_updateProperties", }, // перечислим тут все наши поля // данные свойства работают в обе // стороны, т.е. при изменении полей // в шаблоне, они будут изменяться // в объекте zip: { type: String, value: "" }, street: { type: String, value: "" }, firstname: { type: String, value: "" }, lastname: { type: String, value: "" }, }; } get apollo() { return { getUserInfo: { query: getUserInfoQuery } }; } _updateProperties() { // все что делаем в этом методе // это парсим все необходимые значения // из объекта в отдельные // свойства. // нужно это по той причине // что изменить из шаблона // аттрибуты внутри объекта // (user = {...}) невозможно if (this.user.firstname != undefined) { // использовать индексы плохая практика // не делайте так this.zip = this.user.addresses[0].zip; this.street = this.user.addresses[0].street; this.firstname = this.user.firstname; this.lastname = this.user.lastname; } } // ну и собственно наш виновник торжества // (вариант очень базовый, за более широкими // возможностями почитайте документацию к polymer-apollo // (https://github.com/aruntk/polymer-apollo#mutations) _sendAddressUserMutation() { this.$apollo.mutate({ mutation: updateAddressAndUserMutation, // то, чего вы так ждали // да, это они variables: { id: 1, zip: this.zip, street: this.street, firstname: this.firstname, lastname: this.lastname, }, }).then((data) => { // тут можно проверить что же нам пришло // но мы этого делать, конечно же, // не будем // вызовем обновление компонента // который выведет наши изменения document.getElementById('view-block').dispatchEvent(new CustomEvent('refetch')); }) } } window.customElements.define(UpdateAddressUser.is, UpdateAddressUser);
/src/update-user-address/update-user-address.html:
<dom-module id="update-address-user"> <template> <!-- поля со свойствами из компонента (работают в обе стороны) --> ZIP Code: <input value="{{zip::input}}"><br> Street: <input value="{{street::input}}"><br> First Name: <input value="{{firstname::input}}"><br> Last Name: <input value="{{lastname::input}}"><br> <!-- по нажатию на кнопку шлём данные на сервер --> <button on-click="_sendAddressUserMutation">Send</button> </template> </dom-module>
Шаг 3. Добавим event listener в основной компонент
Чтобы мы могли тут же обновить данные в соседнем компоненте для их вывода после изменения, добавим в него event listener и метод для обновления GraphQL-запроса.
src/graphql-client-demo-app/graphql-client-demo-app.js:
... // добавим eventListener для // внешних компонентов ready() { super.ready(); this.addEventListener('refetch', e => this._refetch(e)); } ... // метод для обновления данных сервера _refetch() { this.$apollo.refetch('getUserInfo'); } ...
Шаг 4. Подключаем новосозданный компонент
index.html:
... <link rel="import" href="/src/graphql-client-demo-app/graphql-client-demo-app.html"> <link rel="import" href="/src/update-address-user/update-address-user.html"> <script src="bundle.js"></script> </head> <body> <graphql-client-demo-app id="view-block"></graphql-client-demo-app> <update-address-user></update-address-user> </body> </html>
entry.js:
import './src/client.js'; import './src/graphql-client-demo-app/graphql-client-demo-app.js'; import './src/update-address-user/update-address-user.js';
Шаг 5. Тестируем
Ну и для начала соберем webpack (если вы все еще не избавились от него):
$> webpack
Открываем браузер и получаем что-то подобное:

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