Sockets In Your API

Given the case for asynchronous PHP, you may be keen to try it but unsure where to start. Perhaps you’ve been working on an API, and you want to add socket support. Let me show you how!

You can find the code on Github. You can find the discussion on Reddit. You should already have PHP 5.4+ and Composer installed.

Shaving Yaks

I’m not going to show you how to build your API. That could fill (Phil?) several books. Rather I want you to imagine you have an API structured like this:

You might be imagining different names for these things, but the point is you should build your API with thin controllers. Controllers aren’t meant to be anything more than HTTP glue. They gather request data, pass it to something else, and return response data.

You could omit repositories, accessing models in your services. What you should steer clear of is doing database work in your HTTP controllers. That’s ok when you’re learning, but it will limit your ability to reuse code when you want to add sockets!

Let’s imagine that this API has a couple entities, requires authentication. We might expect to use endpoints like:

  • POST /tokens → to create new tokens, for authentication
  • POST /posts → to create new posts
  • GET /posts → to read all posts
  • PATCH /posts/123 → to update an existing post
  • DELETE /posts/123 → to delete an existing post

Trying Ratchet

Ratchet is the only viable implementation of the web socket protocol we PHP developers have. It’s great though, so that’s not a problem! Creating a basic socket server is easy. Start by installing Ratchet in a working directory:

→ composer require "cboden/ratchet:0.*"

Then create a server.php file in the same directory:

require "vendor/autoload.php";

use Ratchet\Http\HttpServer;
use Ratchet\Server\EchoServer;
use Ratchet\Server\IoServer;
use Ratchet\WebSocket\WsServer;

$server = IoServer::factory(
new HttpServer(
new WsServer(
new EchoServer()
)
),
8080,
"127.0.0.1"
)
;

$server->run();

Run this file from the terminal:

→ php server.php

Then open your browser (to any page) and run this JS in the console:

var socket = new WebSocket("ws://127.0.0.1:8080");
socket.addEventListener("message", function(e) {
console.log(e.data);
});
socket.send("hello");

And you’ve called the socket.send() method, you should see hello printed in the console. If you don’t see that, one of these might be the problem:

  • Ratchet didn’t install properly. Check the output of the composer command you used to install it.
  • The browser you’re using doesn’t support WebSockets. Try Chrome or Firefox — they keep themselves up to date.
  • Your client and/or server are preventing connections to 127.0.0.1 and/or 8080. This isn’t usually a problem if your client is your server.
Debugging web sockets isn’t fun. It’s also not the point of this post. If you’re having trouble, make a Github issue or send me a tweet.

It’s also worth mentioning that frameworks like Laravel and components like Symfony/Console or Aura/Cli are better approaches than a server.php file. Make a command, if you can. You’ll find it easier to extend in future…

Using Services

To get all posts from the kind of API we’ve been talking about, we’d have to take the following steps:

  1. Make a POST request to /tokens
  2. Get the authentication token from a successful response
  3. Make a GET request to /posts

Sockets are slightly different. For starters, they’re persistent. They are also unbound by HTTP verbs, so there’s no concept of GET or POST requests. When you think about it, those verbs are just part of what’s happening in the controllers and router.

Sockets can interact directly with services, bypassing the controllers. We could create a socket server, which allows us to get all posts, with the following steps:

  1. Open a socket connection
  2. Attach listeners for authentication and service events
  3. Send an authentication message (with username and password)
  4. Listen for successful authentication event
  5. Send service messages

An example authentication message might resemble:

{
type: "tokens.create",
data: {
username: "[your username]",
password: "[your password]"
}
}

…and an example service message might resemble:

{
type: "posts.index",
data: {
page: 1,
limit: 10,
filter: {
since: "2014/12/29 14:30"
},
embed: [
"author",
"comments"
]
}
}
WebSocket messages are just strings sent between the client and server. You’ll need to use JSON.stringify() and JSON.parse() on the client. You can also use json_decode() and json_encode() on the server.

Managing Sessions

Let’s replace the EchoServer with something useful. First, we’ll implement Ratchet\MessageComponentInterface in a simple class:

namespace Formativ;

use Exception;
use Ratchet\ConnectionInterface as Connection;
use Ratchet\MessageComponentInterface;
use SplObjectStorage;

class Server implements MessageComponentInterface
{
/**
* @var SplObjectStorage
*/
protected $connections;

public function __construct()
{
$this->connections = new SplObjectStorage();
}


/**
* @param Connection $connection
*/
public function onOpen(Connection $connection)
{
$this->connections->attach($connection);
}

/**
* @param Connection $connection
* @param string $message
*/
public function onMessage(Connection $connection, $message)
{
$connection->send($message);
}

/**
* @param Connection $connection
*/
public function onClose(Connection $connection)
{
$this->connections->detach($connection);
}

/**
* @param Connection $connection
* @param Exception $exception
*/
public function onError(Connection $connection, Exception $e)
{
$connection->close();
}
}

We’ll need to add this to composer.json:

{
"require": {
"cboden/ratchet": "0.*"
},
"autoload": {
"psr-4": {
"Formativ\\": "src"
}
}
}

…and recreate the autoloader:

→ composer dump-autoload

This is only slightly more useful than EchoServer in that it stores the connections. We can add authentication by adjusting onMessage:

/**
* @param Connection $connection
* @param string $message
*/
public function onMessage(Connection $connection, $message)
{
$message = json_decode($message, true);

if ($message["type"] === "tokens . create") {
$data = [];

if (isset($message["data"])) {
$data = $message["data"];
}

$service = new TokenService();
$response = $service->create($data);


if ($response["status"] === "ok") {
$this->connections[$connection] = [
"token" => $response["data"]["token"],
];


$response = [
"type" => "tokens.create",
"status" => "ok"
];
} else {
$response = [
"type" => "tokens.create",
"status" => "error"
];
}

$connection->send(json_encode($response));
}
}
If you don’t have anything like TokenService then imagine a class that accepts username and password, and returns status and token. You can even emulate it with the following dummy class:
class TokenService
{
/**
* @param array $data
*
* @return array
*/
public function create(array $data)
{
return [
"status" => "ok",
"data" => [
"token" => "new token",
],
];
}
}

Now we’re getting somewhere! We can send authentication messages and store the tokens with the connection that sent the messages. The next step is to check for these tokens before allowing other messages:

if ($message["type"] === "posts.index") {
$meta = $this->connections[$connection];

$token = false;
$data = [];

if (isset($meta["token"])) {
$token = $meta["token"];
}


if (isset($message["data"])) {
$data = $message["data"];
}

$service = new PostService();
$response = $service->index($token, $data);


if ($response["status"] === "ok") {
$message = [
"type" => "posts.index",
"status" => "ok",
"data" => $response["data"],
];
} else {
$message = [
"type" => "posts.index",
"status" => "error"
];
}

$connection->send(json_encode($message));
}
If you don’t have anything like PostService then you’ll have to imagine a class that checks for a token and returns a list of posts. You can even emulate it with the following dummy class:
class PostService
{
/**
* @param string $token
* @param array $data
*
* @return array
*/
public function index($token, array $data)
{
if ($token === false) {
return [
"status" => "error",
];
}


return [
"status" => "ok",
"data" => [
"post #1",
"post #2",
"post #3",
],
];
}
}

We should refactor this a little:

/**
* @param Connection $connection
* @param string $message
*/
public function onMessage(Connection $connection, $message)
{
$message = json_decode($message, true);

if ($this->getTypeFrom($message) === "tokens.create") {
$service = new TokenService();

$response = $service->create(
$this->getDataFrom($message)
);

if ($this->getStatusFrom($response) === "ok") {
$this->connections[$connection] = [
"data" => [
"token" => $this->getTokenFrom($response),
],
];

return $this->respondWithOk(
$connection,
"tokens.create"
)
;
}

return $this->respondWithError(
$connection,
"tokens.create"
)
;
}

if ($this->getTypeFrom($message) === "posts.index") {
$meta = $this->connections[$connection];

if (empty($meta)) {
$meta = [];
}

$service = new PostService();

$response = $service->index(
$this->getTokenFrom($meta),
$this->getDataFrom($message)
);

if ($this->getStatusFrom($response) === "ok") {
return $this->respondWithOk(
$connection,
"posts.index",
$this->getDataFrom($response)
)
;
}

return $this->respondWithError(
$connection,
"posts.index"
)
;
}
}

/**
* @param array $message
* @param mixed $default
*
* @return mixed
*/
protected function getTypeFrom(
array $message,
$default = "unknown"
)
{
return $this->getFrom($message, "type", $default);
}

/**
* @param array $message
* @param string $key
* @param mixed $default
*
* @return mixed
*/
protected function getFrom($message, $key, $default = null)
{
if (isset($message[$key])) {
return $message[$key];
}

return $default;
}

/**
* @param array $message
* @param mixed $default
*
* @return mixed
*/
protected function getDataFrom(array $message, $default = [])
{
return $this->getFrom($message, "data", $default);
}

/**
* @param array $message
* @param mixed $default
*
* @return mixed
*/
protected function getStatusFrom(array $message, $default = [])
{
return $this->getFrom($message, "status", $default);
}

/**
* @param array $message
* @param mixed $default
*
* @return mixed
*/
protected function getTokenFrom(array $message, $default = false)
{
if (isset($message["data"])) {
return $this->getFrom($message["data"], "token", $default);
}

return $default;
}

/**
* @param Connection $connection
* @param string $type
* @param array $data
*/
protected function respondWithOk(
$connection,
$type,
array $data = []
)
{
$this->respond($connection, $type, "ok", $data);
}

/**
* @param Connection $connection
* @param string $type
* @param string $status
* @param array $data
*/
protected function respond(
$connection,
$type,
$status,
array $data = []
)
{
$message = [
"type" => $type,
"status" => $status,
];

if (count($data) > 0) {
$message["data"] = $data;
}

$connection->send(json_encode($message));
}

/**
* @param Connection $connection
* @param string $type
* @param array $data
*/
protected function respondWithError(
$connection,
$type,
array $data = []
)
{
$this->respond($connection, $type, "error", $data);
}

Finally, we can see the authentication happening, with the following JS:

var socket = new WebSocket("ws://127.0.0.1:8080");
socket.addEventListener("message", function(e) {
console.log(e.data);
});
socket.send(JSON.stringify({
type: "posts.index"
}));
// results in {"type":"posts.index","status":"error"}
socket.send(JSON.stringify({
type: "tokens.create"
}));
// results in {"type":"tokens.create","status":"ok"}
socket.send(JSON.stringify({
type: "posts.index"
}));
// results in {"type":"posts.index","status":"ok",...}

Wrapping Up

Adding sockets to your API isn’t hard, provided you’ve done the work of organising it well. It’s just as easy to create a full console-based interface to your application.

If you’re stuck with any of this, send me a tweet or make a Github issue. I’ll be glad to help!

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.