Налаштування зв’язки ZF3+Doctrine2+GraphQL

Table of Content

WEB змінюється дуже швидко. Ще декілька років тому головним mainstream & best practise для реалізації API вважався REST, то сьогодні він вже має багато недоліків і всі використовують GraphQL, як єдино правильний варіант для роботи через API.

Якщо серйозно, то в цих словах багато сарказму, кожен вирішує сам, що є кращим для нього. Свого часу з появою NoSQL баз даних (Mongo) весь Інтернет гудів, що от ми знайшли срібну кулю від усіх бід і тепер заживемо щасливо. Мало того, багато хто ще тоді похоронив SQL бази даних і очікував швидкої їх смерті. Пройшло порядку 10 років, MySQL, MSSQL, PostgreSQL та інші SQL бази даних не вмерли, вони випускають нові релізи і додають в них нові плюшки.

Єдине що дійсно, не потрібно відставати від тенденцій і при нагоді пробувати нові технології, хто зна можливо вони вам сподобаються.

Загальні відомості про GraphQL

GraphQL водночас є простою і складною. Простою вона може бути для клієнта, який отримує дані з сервера. Щодо грамотного налаштування серверної частини, то це може вимагати багато сил, і є доволі складною процедурою.

Одним з головних аргументів чому GraphQL is true при виборі платформи для реалізації API є те, що єдина точнка входу в ваше API залишається не змінною, ви просто нарощуєте функціонал і клієнт за потреби його використовує. В тому ж REST при реалізації нових можливостей потрібно було б змінити версію в URL, наприклад /api/v1/ -> /api/v2/.

Налаштування

Наявна структура з Doctrine моделей в короткі терміни дає можливість отримати базову версію API, потрібно просто передати моделі в модуль GraphQL Doctrine і вони відображаться в Schema.

Doctrine Модель

Проста, без зв’язків модель для отримання інформації по маркетплейсах.

namespace Stagem\Amazon\Model;

use Doctrine\ORM\Mapping as ORM;
use GraphQL\Doctrine\Annotation as API;

use Popov\ZfcCore\Model\DomainAwareTrait;
use Stagem\ZfcPool\Model\PoolInterface;

/**
 * @ORM\Entity(repositoryClass="Stagem\Amazon\Model\Repository\MarketplaceRepository")
 * @ORM\Table(name="amazon_marketplace")
 */
class Marketplace implements PoolInterface
{
    const MNEMO = 'marketplace';
    const TABLE = 'amazon_marketplace';

    use DomainAwareTrait;

    /**
     * @var int
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer", options={"unsigned":true})
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", length=14, unique=true, options={"unsigned":true})
     */
    private $code;

    /**
     * @var string
     * @ORM\Column(type="string", nullable=true, length=255)
     */
    private $name;

    /**
     * @var string
     * @ORM\Column(type="string", nullable=true, unique=true, length=255)
     */
    private $domain;

    /**
     * @return string
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @param string $id
     * @return Marketplace
     */
    public function setId($id): Marketplace
    {
        $this->id = $id;

        return $this;
    }

    /**
     * @return string
     */
    public function getCode(): string
    {
        return $this->code;
    }

    /**
     * @param string $code
     * @return Marketplace
     */
    public function setCode(string $code): Marketplace
    {
        $this->code = $code;

        return $this;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @param string $name
     * @return Marketplace
     */
    public function setName(string $name): Marketplace
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return string
     */
    public function getDomain(): string
    {
        return $this->domain;
    }

    /**
     * @param string $domain
     * @return Marketplace
     */
    public function setDomain(string $domain): Marketplace
    {
        $this->domain = $domain;
        return $this;
    }

    /**
     * @return string
     */
    public function getCountryCode(): string
    {
        return trim(str_replace(['www', 'amazon'], '', $this->getDomain()), '.');
    }
}

Налаштування Action

В GraphQL точка входу завжди залишається стабільно не змінною. За замовчуванням URL має вигляд /graphql і до нього не додаються більше жодні параметри.
Створимо Action на який будуть спрямовуватись всі API запити, він водночас буде генерувати Schema і повертати дані.

namespace Stagem\ZfcGraphQL\Action\Admin;

use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
//use Psr\Http\Server\MiddlewareInterface;
//use Psr\Http\Server\RequestHandlerInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Interop\Http\Server\RequestHandlerInterface;

use Fig\Http\Message\RequestMethodInterface;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\ServiceManager\ServiceManager;

use GraphQL\GraphQL;
use GraphQL\Type\Schema;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Server\StandardServer;
use GraphQL\Doctrine\DefaultFieldResolver;
use GraphQL\Doctrine\Types;
use Stagem\Amazon\Model\Marketplace;

class IndexAction implements MiddlewareInterface, RequestMethodInterface
{
    /**
     * @var ServiceManager
     */
    protected $container;

    /**
     * @var EntityManager
     */
    protected $entityManager;

    public function __construct(ContainerInterface $container, EntityManager $entityManager)
    {
        $this->container = $container;
        $this->entityManager = $entityManager;

        /** @var \Doctrine\ORM\Configuration $doctrineConfig */
        // @todo remove when will be fixed @see https://github.com/Ecodev/graphql-doctrine/issues/21#issuecomment-432064584
        $doctrineConfig = $this->container->get('doctrine.configuration.orm_default');
        $doctrineConfig->getMetadataDriverImpl()
            ->setDefaultDriver(new \Doctrine\ORM\Mapping\Driver\AnnotationDriver(
                new \Doctrine\Common\Annotations\AnnotationReader()
            ));
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        #$this->container->setAllowOverride(true);
        #$this->container->setInvokableClass(DateTime::class, DateTimeType::class);
        #$this->container->setAllowOverride(false);

        // Configure the type registry
        $types = new Types($this->entityManager, $this->container);

        // Configure default field resolver to be able to use getters
        GraphQL::setDefaultFieldResolver(new DefaultFieldResolver());

        try {
            $queryType = new ObjectType([
                'name' => 'query', // @todo Try change to Query
                'fields' => [
                    'marketplace' => [
                        'type' => $types->getOutput(Marketplace::class), // Use automated ObjectType for output
                        'description' => 'Returns marketplace by id (in range of 1-6)',
                        'args' => [
                            'id' => Type::nonNull(Type::id())
                        ],
                        'resolve' => function ($root, $args) use ($types) {
                            $queryBuilder = $types->createFilteredQueryBuilder(Marketplace::class, $args['filter'] ?? [], $args['sorting'] ?? []);

                            $result = $queryBuilder->getQuery()->getArrayResult();

                            return $result;
                        },
                    ],
                    'marketplaces' => [
                        'type' => Type::listOf($types->getOutput(Marketplace::class)), // Use automated ObjectType for output
                        'args' => [
                            [
                                'name' => 'filter',
                                'type' => $types->getFilter(Marketplace::class), // Use automated filtering options
                            ],
                            [
                                'name' => 'sorting',
                                'type' => $types->getSorting(Marketplace::class), // Use automated sorting options
                            ],
                        ],
                        'resolve' => function ($root, $args) use ($types) {
                            $queryBuilder = $types->createFilteredQueryBuilder(Marketplace::class, $args['filter'] ?? [], $args['sorting'] ?? []);

                            $result = $queryBuilder->getQuery()->getArrayResult();

                            return $result;
                        },
                    ],
                ],
                'resolveField' => function($val, $args, $context, ResolveInfo $info) {
                    return $this->{$info->fieldName}($val, $args, $context, $info);
                }
            ]);

            /*$mutationType = new ObjectType([
                'name' => 'mutation',
                'fields' => [
                    'createMarketplace' => [
                        'type' => Type::nonNull($types->getOutput(Marketplace::class)),
                        'args' => [
                            'input' => Type::nonNull($types->getInput(Marketplace::class)), // Use automated InputObjectType for input
                        ],
                        'resolve' => function ($root, $args): void {
                            // create new post and flush...
                        },
                    ],
                    'updateMarketplace' => [
                        'type' => Type::nonNull($types->getOutput(Marketplace::class)),
                        'args' => [
                            'id' => Type::nonNull(Type::id()), // Use standard API when needed
                            'input' => $types->getPartialInput(Post::class),  // Use automated InputObjectType for partial input for updates
                        ],
                        'resolve' => function ($root, $args): void {
                            // update existing post and flush...
                        },
                    ],
                ],
            ]);*/

            // See docs on schema options:
            // http://webonyx.github.io/graphql-php/type-system/schema/#configuration-options
            $schema = new Schema([
                'query' => $queryType,
                //'mutation' => $mutationType,
            ]);

            $schema->assertValid();

            // See docs on server options:
            // http://webonyx.github.io/graphql-php/executing-queries/#server-configuration-options
            $server = new StandardServer([
                'schema' => $schema
            ]);

            $server->handleRequest();
        } catch (\Exception $e) {
            StandardServer::send500Error($e);
        }

        return new EmptyResponse();
    }
}

Mutation закоментовано свідомо щоб спрости реалізацію і не розсіювати увагу.
Власне це все, у $queryType['fields'] було додано два поля marketplace та marketplaces які повернуть Schema для нашої моделі Marketplace.
В список попорядку додаються нові моделі або за потреби пишеться скрипт який зробить це автоматично.

Робота з GraphiQL

Для тестування API GraphQL пропонує веб-інтерфейс GraphiQL, щоб його налаштувати, потрібно трішки повозитись. Зручніше на перший випадок використовувати додаток браузера ChromeiQL або будь який інший, всі вони мають схожий інтерфейс і виконують тіж самі функції.

Встановлюємо, відкриваємо, встановлюємо точку входу в наше API, наприклад http://localhost/admin/graphql, тиснемо Set Endpoint.

Виконуємо перший запит. З клієнтської сторони виглядає все дуже зручно й прозоро

{
  marketplaces {
    id
    code
    name
    domain
  }
}

У відповідь мають прийти ваші дані з бази даних. Щось на кшталт:

{
  "data": {
    "marketplaces": [
      {
        "id": "1",
        "name": "DE",
        "domain": "www.amazon.de"
      },
      {
        "id": "2",
        "name": "FR",
        "domain": "www.amazon.fr"
      },
      {
        "id": "3",
        "name": "IT",
        "domain": "www.amazon.it"
      }
    ]
  }
}

На правій панелі можна переглянути нашу схему, клікаючи на пункти буде відбуватись перехід на дочірні елементи.

Далі перевіримо фільтрацію і переконаємось, що все працює справно.
Для цього наш запит потрібно огорнути в конструкцію query і надати йому ім’я. Запит буде мати такий вигляд:

query GetMarketplaces($filter: MarketplaceFilter) {
  marketplaces(filter: $filter) {
    id
    name
    domain
  }
}

В Query Variables вставляємо тіло запиту. Конструкція доволі гроіздка але універсальна.

{
  "filter": {
    "groups": [{
      "conditions": [{        
        "code": {
            "equal": {
            "value": "A13V1IB3VIYZZH"
          }
        }
      }]
    }]
  }
}

Отримуємо результат:

{
  "data": {
    "marketplaces": [
      {
        "id": "2",
        "name": "FR",
        "domain": "www.amazon.fr"
      }
    ]
  }
}

На почитати
Thinking in Graphs
Awesome list of GraphQL & Relay
Webonyx GraphQL PHP documentation
Відео – GraphQL Introduction (укр.)
Відео – GraphQL Basic, Apollo (укр.)

Leave a Reply

Your email address will not be published. Required fields are marked *