Build a GraphQL API with Siler on top of Swoole

Published:

By @leocavalcante

I'm assuming you already know what is GraphQL and Swoole, so what about getting started right straight to the code, shall we?

What you maybe doesn't know yet is about Siler! It is a set of general purpose high-level abstractions aiming an API for declarative programming in PHP. With Siler we can abstract way "hard" parts from popular tools like GraphQL and recently Swoole. And yes, I'm the author, so feel free to file any issues or make any questions about it.

"Talk is cheap, show me the code". Let's go!

I want to make it simple as possible so we can focus on using GraphQL and Swoole through Siler instead of solving hard domain and business logic problems, so we are going to implement a gold-old to-do list.

$ mkdir todos
$ cd todos/

Don't know about you, but I really like to start a project from a clean greenfield instead of using boilerplate/skeleton code.

$ composer require leocavalcante/siler
$ composer require webonyx/graphql-php
$ composer require --dev swoole/ide-helper

That is our deps. Siler itself doesn't re-implements a GraphQL parser/executor, it builds on top of the Webonyx's current work, same for Swoole, of course, so make sure you have Swoole extension up-n-running on your PHP environment. The schema A Todo is simple, we need a ID to uniquely identify it, a title to work as a short description, a body to work as a full description and a flag to tell if it is already done.

type Todo {
  id: Int
  title: String
  body: String
  done: Boolean
}

We also need a type to work as an Input and of couse, if you know GraphQL, a Query type to query Todos and a Mutation type to create, change and delete them. Here is our full schema:

type Todo {
  id: Int
  title: String
  body: String
  done: Boolean
}

input TodoInput {
  title: String
  body: String
}

type Query {
  todos: [Todo]
  todo(id: Int): Todo
}

type Mutation {
  saveTodo(input: TodoInput): Todo
}

That is it! Let's go to PHP then.

The server

Again, assuming you already know, but… using Swoole we build our own HTTP server, just like Node.js, it's a good, shall we?

<?php declare(strict_types=1);

namespace App;

use Swoole\Http\Request;
use function Siler\Swoole\http;
use function Siler\Swoole\json;

$basedir = __DIR__;
require_once "$basedir/vendor/autoload.php";

$handler = function (Request $request) {
    json('It works');
};

$port = getenv('PORT') ? intval(getenv('PORT')) : 8000;
echo "Listening on http://localhost:$port\n";
http($handler, $port)->start();

You probably saw something different at Swoole's documentation. That is Siler working! Making it even more simple and enjoyable. Just like regular PHP functions, but borrowing pureness, immutability and high-order as much as possible from the Functional Programming paradigm. Start the server using php index.php head to http://localhost:8000 (or whatever port you override using the PORT environment variable) and check if it's working.

The domain

Now it's time work on our domain.

First we define our Todos module. It's the module that will hold the functions that work on a Todo. I know, it's not Object Oriented-ish, but I really don't care, I don't want to. We can work on this using whatever paradigm you like, you will see that it's not coupled to rest of the code.

<?php declare(strict_types=1);

namespace App\Todo;

interface Todos
{
    public function find(Criteria $criteria): array;
    public function save(array $todo): array;
}

For now, that is what we are going to do, find and save. find can receive a Criteria that's how you can build a lot of different ways to query for a Todo. They are simple:

<?php declare(strict_types=1);

namespace App\Todo;

interface Criteria
{
}
<?php declare(strict_types=1);

namespace App\Todo\Criteria;

use App\Todo\Criteria;

class FindAll implements Criteria
{
}
<?php declare(strict_types=1);
namespace App\Todo\Criteria;
use App\Todo\Criteria;
class FindOne implements Criteria
{
    /** @var int */
    private $id;

    public function __construct(int $id)
    {
        $this->id = $id;
    }

    public function getId(): int
    {
        return $this->id;
    }
}

The application

I'm not trying to accomplish DDD here, but hey, that's a cool concept, right? After defining our domain, we are ready to move to the next layer and starting building the application layer. Nothing more clean and useful for testing then an in-memory implementation of some I/O.

<?php declare(strict_types=1);

namespace App\Todo;

use App\Todo\Criteria\FindAll;
use App\Todo\Criteria\FindOne;

class InMemoryTodos implements Todos
{
    private $memory = [];

    public function find(Criteria $criteria): array
    {
        if ($criteria instanceof FindAll) {
            return $this->memory;
        }
        if ($criteria instanceof FindOne) {
            return $this->memory[$criteria->getId()] ?? null;
        }
        return [];
    }

    public function save(array $todo): array
    {
        if (empty($todo['id'])) {
            // NOTE: Is new
            $todo['id'] = sizeof($this->memory) + 1;
            $todo['done'] = false;
            $this->memory[$todo['id']] = $todo;
            return $todo;
        } else {
            // NOTE: Is an update
            $this->memory[$todo['id']] = array_merge($this->memory[$todo['id']], $todo);
            return $this->memory[$todo['id']];
        }
    }
}

Very clear what is going on here, right? We are finding and saving on an in-memory array. Having the Todos interface and working on its implementations opens a wide range of possibilities from testing using in-memory implementations to real work at PDO or NoSQL implementations. You will get assimilate now when you see how Resolvers are built:

The resolvers

<?php declare(strict_types=1);

namespace App\Todo\Resolver;

use App\Todo\Criteria;
use App\Todo\Todos;

class Query
{
    /** @var Todos */
    private $todos;
    /** @var Criteria */
    private $criteria;

    public function __construct(Todos $todos, Criteria $criteria)
    {
        $this->todos = $todos;
        $this->criteria = $criteria;
    }

    public function __invoke()
    {
        return $this->todos->find($this->criteria);
    }
}
<?php declare(strict_types=1);

namespace App\Todo\Resolver;

use App\Todo\Todos;

class Save
{
    private $todos;

    public function __construct(Todos $todos)
    {
        $this->todos = $todos;
    }

    public function __invoke(?array $root, array $args)
    {
        $title = filter_var($args['input']['title'], FILTER_SANITIZE_STRING);
        $body = filter_var($args['input']['body'], FILTER_SANITIZE_STRING);
        $todo = ['title' => $title, 'body' => $body];
        return $this->todos->save($todo);
    }
}

The Query resolver just basically binds a Criteria to a Todos module. Note that both resolvers depends on the abstraction of a Todos module, not an implementation. The Save resolver shows a little more about is functionally, maybe you assimilate them to a Controller of the MVC pattern. Now we need to bring these pieces together in a way that is easy to later tell the Schema builder what resolver it should be using. Well, what about a factory?

<?php declare(strict_types=1);

namespace App;

use App\Todo\Criteria\FindAll;
use App\Todo\Criteria\FindOne;
use App\Todo\Resolver\Query;
use App\Todo\Resolver\Save;
use App\Todo\Todos;

function create_resolvers(Todos $todos)
{
    return [
        'Query' => [
            'todos' => new Query($todos, new FindAll()),
            'todo' => function (?array $root, array $args) use ($todos) {
                // Please, come fast arrow functions!
                return (new Query($todos, new FindOne($args['id'])))();
            },
        ],
        'Mutation' => [
            'saveTodo' => new Save($todos),
        ],
    ];
}

The server (update)

Our source-code is all there, our domain, its implementations and resolvers working as the application. Now is time to glue all this to the server:

<?php declare(strict_types=1);

namespace App;

use App\Todo\InMemoryTodos;
use GraphQL\Error\Error;
use Swoole\Http\Request;
use function Siler\GraphQL\execute;
use function Siler\GraphQL\schema;
use function Siler\Swoole\http;
use function Siler\Swoole\json;

$basedir = __DIR__;
require_once "$basedir/vendor/autoload.php";

$todos = new InMemoryTodos();

$type_defs = file_get_contents("$basedir/res/schema.graphql");
$schema = schema($type_defs, create_resolvers($todos));

$handler = function (Request $request) use ($schema) {
    json(execute($schema, json_decode($request->rawContent(), true, 512, JSON_THROW_ON_ERROR)));
};

$port = getenv('PORT') ? intval(getenv('PORT')) : 8000;
echo "Listening on http://localhost:$port\n";
http($handler, $port)->start();

And there is our GraphQL API running on top of Swoole with the help of Siler!

I hope you enjoyed it. Feel free to ask any questions.

Full source-code is available here.

Thanks!