Пример создания GraphQL API на Laravel. Часть 2

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

Получение вложенных ресурсов

Одним из главных преимуществ GraphQL перед REST API является возможность загрузить все вложенные ресурсы, которые нам нужны, за один запрос, и только нужные поля. В REST для этого нужно запрашивать каждый тип ресурса по отдельности.

Рассмотрим как это сделать на  примере получения списка постов пользователей. Для этого добавим новую модель Post и новый тип данных PostType.

Создадим таблицу posts с помощью следующей миграции:

<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddPostsTable extends Migration
{
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->string('title');
            $table->integer('user_id');
            $table->timestamps();
            $table->foreign('user_id')->references('id')->on('users');
        });
    }

    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

В модели Post просто укажем только что созданную таблицу:

<?php
namespace App;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $table = 'posts';
}

И наконец добавим новый тип ресурса PostType:

<?php

namespace App\GraphQL\Type;

use Folklore\GraphQL\Support\Type as BaseType;
use GraphQL\Type\Definition\Type;

class PostType extends BaseType
{
    protected $attributes = [
        'name' => 'PostType',
        'description' => 'A blog posts type'
    ];

    public function fields()
    {
        return [
            'id' => [
                'type' => Type::nonNull(Type::int())
            ],
            'title' => [
                'type' => Type::nonNull(Type::string()),
            ],
            'user_id' => [
                'type' => Type::nonNull(Type::int()),
            ],
            'created_at' => [
                'type' => Type::nonNull(Type::string()),
            ],
            'updated_at' => [
                'type' => Type::nonNull(Type::string()),
            ]
        ];
    }
}

Массив типов данных в конфигурации будет выглядеть так:

// config/graphql.php
'types' => [
    'User' => 'App\GraphQL\Type\UserType',
    'Post' => 'App\GraphQL\Type\PostType',
]

Теперь мы можем добавить новую связь один ко многим в модель App\User:

public function posts()
{
    return $this->hasMany('App\Post');
}

и добавить новое поле posts в тип UserType:

'posts' => [
    'args' => [
        'id' => [
            'type'        => Type::int(),
            'description' => 'id of the post',
        ],
    ],
    'type' => Type::listOf(GraphQL::type('Post')),
    'description' => 'User`s posts',
],

Поле posts является массивом записей с типом Post. Так же мы добавили дополнительный аргумент id, с помощью которого можно будет запросить только конкретные записи пользователя. Теперь нужно определить, как будет генерироваться значение поля posts. Для этого в нужно добавить метод-резолвер в формате  resolve[FIELD_NAME]Field(), то есть в нашем случае resolvePostsField():

public function resolvePostsField($root, $args)
{
    if (isset($args['id'])) {
        return  $root->posts->where('id', $args['id']);
    }

    return $root->posts;
}

Теперь можно за один запрос к API получить информацию о пользователе и его постах. Запрос будет выглядеть следующим образом:

query {
  users (id: 1) {
    id
    email
    posts {
      id
      title
    }
  }
}

и ответ от сервера (я вручную добавил несколько тестовых записей в таблицу posts):

{
  "data": {
    "users": [
      {
        "id": 1,
        "email": "test@example.com",
        "posts": [
          {
            "id": 1,
            "title": "Test post 1"
          },
          {
            "id": 2,
            "title": "Test post 2"
          }
        ]
      }
    ]
  }
}

Кастомные типы полей

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

Создадим новый тип DateTimeField, чтобы получать значение полей с типом timestamp, которые Eloquent автоматически преобразует в объекты Carbon, в нужном нам строковом формате:

<?php
namespace App\GraphQL\Field;

use Folklore\GraphQL\Support\Field;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;

class DateTimeField extends Field
{
    protected $attributes = [
        'description' => 'String representation for Carbon date/time object'
    ];

    public function type()
    {
        return Type::string();
    }

    public function args()
    {
        return [
            'format' => [
                'type' => Type::string(),
                'description' => 'Formatting date based on DateTime::format() specs'
            ]
        ];
    }

    protected function resolve($root, $args, $context, ResolveInfo $info)
    {
        $field = $root->{$info->fieldName};
        return isset($args['format'])
            ? $field->format($args['format'])
            : $field->toDateTimeString();
    }
}

В основном нас интересует метод resolve, в который передается модель, для которой нужно получить поле, аргументы (мы объявили один аргумент — format, с помощью которого задается нужный формат даты) и объект ResolveInfo, из которого мы получаем имя поля. Если формат не задан, мы просто возвращаем дату и время в формате по умолчанию (‘Y-m-d H:i:s’).

Теперь в классе PostType нужно обновить объявление типов полей created_at и updated_at:

'created_at' => DateTimeField::class,
'updated_at' => DateTimeField::class,

Теперь мы можем получить дату и время в нужном нам формате. Запрос:

query {
  users (id: 1) {
    id
    posts {
      title
      updated_at (format: "Y-m-d")
    }
  }
}

и ответ сервера:

{
  "data": {
    "users": [
      {
        "id": 1,
        "posts": [
          {
            "title": "Test post 1",
            "updated_at": "2018-02-04"
          },
          {
            "title": "Test post 2",
            "updated_at": "2018-02-04"
          }
        ]
      }
    ]
  }
}

Жадная загрузка зависимых моделей (eager loading)

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

Чтобы этого избежать, можно воспользоваться жадной загрузкой Eloquent (eager loading), тогда при получении списка пользователей, посты для этих пользователей будут получены за 1 запрос.

В классе UsersQuery изменим метод resolve следующим образом:

public function resolve($root, $args, $context, ResolveInfo $info)
{
    $users = User::query();

    if (isset($args['id'])) {
        $users->where('id' , $args['id']);
    } else if(isset($args['email'])) {
        $users->where('email', $args['email']);
    }

    foreach ($info->getFieldSelection() as $field => $keys) {
        if ($field === 'posts') {
            $users->with('posts');
        }
    }

    return $users->get();
}

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

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

Заключение

Мы рассмотрели основные возможности, которые предоставляет API на GraphQL на примере пакета folklore/graphql для Laravel.

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

Как и всегда, для запуска API из этой статьи вы можете скачать код с GitHub по ссылке https://github.com/RusinovIG/blog-examples/tree/graphql-example-2 и запустить локально.

Почитать обо всех возможностях GraphQL подробнее можно в документации к рассмотренному пакету в официальной спецификации GraphQL:

Расскажите, использовали ли вы GraphQL в реальных проектах?

Пример создания GraphQL API на Laravel. Часть 2: 10 комментариев

  1. Есть ли возможность переименовывать названия полей. Использовать алиасы? Насколько я понял folklore/graphql пока так не может?

    1. Можно создавать полностью кастомные поля. На примере resolvePostsField видно, что можно создать поле ‘custom’, и с помощью метода resolveCustomField указать логику вычисления этого поля.
      Возможно так же можно использовать не только имена полей БД, но и кастомные атрибуты, заданные с помощью мутаторов Eloquent https://laravel.com/docs/5.6/eloquent-mutators#defining-an-accessor

      1. Спасибо! Получилось с мутаторами, но это конечно не самый правильный вариант, надеюсь в будущем добавят алиасы, чтобы не создавать множество мутаторов. В https://github.com/rebing/graphql-laravel вроде как есть алиасы, но я не пробовал этот пакет.

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

  2. Добрый день! Есть ли возможность для кастомных полей изменять названия, например не `created_at`, а `createdAt`?

    1. Для кастомных полей можно задавать какие угодно названия и получать значение с помощью резолверов. Если хочется, чтобы camelCase атрибуты работали в модели автоматически, можно использовать что-то подобное: https://stackoverflow.com/a/33710995/2894442

      1. Игорь, спасибо за ответ! По поводу преобразования атрибутов очень полезная информация. Это мне пригодится в будущих проектах. Но я не могу в текущем проекте это сделать.
        У меня проблема в том, что я не знаю как назначить новый тип (как у Вас с `’created_at’ => DateTimeField::class,`). Может есть подробная документация, но я там этого не нашел.

          1. Добрый день! Нужно сделать название поля как `createdAt` из `created_at` c типом поля DateTimeField::class. Если конечно такое возможно..

        1. Ну как вариант, можно по аналогии с решением со stack overflow, добавить обработчик только одного нужного атрибута в вашу модель:
          public function getAttribute($key)
          {
          if ($key == ‘createdAt’) {
          $key = ‘created_at’;
          }
          return parent::getAttribute($key);
          }
          Тогда можно будет использовать новый атрибут: ’createdAt’ => DateTimeField::class

Добавить комментарий

Этот сайт использует Akismet для борьбы со спамом. Узнайте как обрабатываются ваши данные комментариев.