Building an API Endpoint with Amp (PHP)

Share on facebook
Facebook
Share on twitter
Twitter
Share on reddit
Reddit
Share on linkedin
LinkedIn

API endpoints in web apps are pretty typical these days, but there may be reasons to provide data outside of the context of an application. Maybe the application is legacy and you’d rather not modify it, or perhaps it’s not as well-written as it should be and adding new features is a pain. Or maybe you don’t even have an application; maybe you just have a database with some content you’d like to make available.

In this post, we’ll explore how to make a stand-alone API endpoint using PHP and Amp, a “non-blocking concurrency framework for PHP.” Amp provides “primitives to manage concurrency such as an event loop, promises, and asynchronous iterators,” and if all that sounds kind of overwhelming… welcome to the club! We don’t understand it all either, but we can still have fun with it.

Amp has several packages, all of which can be installed with Composer. For this exercise, we’re going to use the Amp http-server as well as their MySQL package. We’ll also need some data to play with; for this example, I’m using a table called “events” which tracks imaginary calendar events. My table is defined as follows:

CREATE TABLE events (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    description TEXT NOT NULL,
    start DATETIME NOT NULL,
    end DATETIME,
    created DATETIME,
    modified DATETIME
);
insert into events (name, description, start, end, created, modified) values ('A Big Event', 'This is a big event.', NOW(), null, NOW(), null);
insert into events (name, description, start, end, created, modified) values ('Another Big Event', 'This is another big event.', NOW(), null, NOW(), null);

Note: this tutorial assumes that you’re working on Linux or Mac, or – if on Windows – you’re using the Linux subsystem.

Loading the Libraries

Create a new folder (such as “events-api”) for your project, cd into it, and use Composer to add in the Amp libraries:

composer require amphp/http-server-mysql
composer require amphp/http-server-router
composer require amphp/mysql
composer require amphp/log

Edit a new file (I called mine “events-api.php”) and start with the following code:

#!/usr/local/bin/php
<?php

require_once __DIR__ . '/vendor/autoload.php';

DEFINE('DB_HOST', 'localhost');
DEFINE('DB_USER', 'root');
DEFINE('DB_PASS', 'password');
DEFINE('DB_NAME', 'eventdb');

use Amp\ByteStream\ResourceOutputStream;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestHandler\CallableRequestHandler;
use Amp\Http\Server\Response;
use Amp\Http\Server\Server;
use Amp\Http\Status;
use Amp\Log\ConsoleFormatter;
use Amp\Log\StreamHandler;
use Amp\Socket;
use Amp\Http\Server\Router;
use Amp\Mysql;
use Monolog\Logger;

Let’s take a look at what we have so far.

The line !/usr/local/bin/php is an interpreter directive; it allows us to run this script from the command line just like we might a Bash script. (You’ll also need to “chmod a+x” the file to make it runnable.)

If you’re familiar with Composer, you’ve no doubt seen the next line:

require_once __DIR__ . '/vendor/autoload.php';

That’s the standard Composer “autoload” that ensures that all libraries included via Composer get loaded with our program.

The next four lines are are database credentials:

DEFINE('DB_HOST', 'localhost');
DEFINE('DB_USER', 'root');
DEFINE('DB_PASS', 'password');
DEFINE('DB_NAME', 'eventdb');

And then we have a whole lot of “use” statements. Most of these are just drawn from the Amp documentation; it’s the stuff we need to do what we’re about to do.

The Webserver Block

Now for some action! This next block of code will handle serving up our endpoints:

Amp\Loop::run(function () {
    $servers = [
        Socket\listen("0.0.0.0:1337"),
        Socket\listen("[::]:1337"),
    ];

    $logHandler = new StreamHandler(new ResourceOutputStream(\STDOUT));
    $logHandler->setFormatter(new ConsoleFormatter);
    $logger = new Logger('server');
    $logger->pushHandler($logHandler);

    $router = new Router;

    $router->addRoute('GET', '/', new CallableRequestHandler(function () {
        return new Response(Status::OK, ['content-type' => 'text/plain'], 'Hello, world!');
    }));
    $router->addRoute('GET', '/name/{searchterm}', new CallableRequestHandler(function (Request $request) {
        $args = $request->getAttribute(Router::class);
        return getDBResponse('name', $args['searchterm']);
    }));
	$router->addRoute('GET', '/desc/{searchterm}', new CallableRequestHandler(function (Request $request) {
		$args = $request->getAttribute(Router::class);
		return getDBResponse('desc', $args['searchterm']);
	}));

    $server = new Server($servers, $router, $logger);

    yield $server->start();

    // Stop the server when SIGINT is received (this is technically optional, but it is best to call Server::stop()).
    Amp\Loop::onSignal(SIGINT, function (string $watcherId) use ($server) {
        Amp\Loop::cancel($watcherId);
        yield $server->stop();
    });
});

Let’s break it down. If you’ve ever set up Nginx or Apache, these lines probably explain themselves:

    $servers = [
        Socket\listen("0.0.0.0:1337"),
        Socket\listen("[::]:1337"),
    ];

This is pretty standard “listen” syntax for IP4/6, and the “0.0.0.0.” says we’ll listen on any interface. Of course we chose 1337 as our port, because we’re LAMPStack Ninjas and who’s more l33t than a ninja? (Okay, maybe Vikings.)

For this tutorial, we’ll log everything to STDOUT:

    $logHandler = new StreamHandler(new ResourceOutputStream(\STDOUT));
    $logHandler->setFormatter(new ConsoleFormatter);
    $logger = new Logger('server');
    $logger->pushHandler($logHandler);

In this example, we’re going to use two endpoints. Users will be able to call “/name” to search our events table name field, and “/desc” to search the description field. In both cases, a search term will also get passed, so that if, for example, we call:

http://localhost:1337/name/big

…we should get hits on both rows from the test data we entered above.

It starts with declaring a new router:

$router = new Router;

Next, we define a response to issue if someone passes no parameters at all:

    $router->addRoute('GET', '/', new CallableRequestHandler(function () {
        return new Response(Status::OK, ['content-type' => 'text/plain'], 'Use "/name/[searchtext]" or "/desc/[searchtext]"');
    }));

In this example, I’m returning some simple how-to-use instructions. Our next two routes are the real ones for which we want to return data:

    $router->addRoute('GET', '/name/{searchterm}', new CallableRequestHandler(function (Request $request) {
        $args = $request->getAttribute(Router::class);
        return getDBResponse('name', $args['searchterm']);
    }));
	$router->addRoute('GET', '/desc/{searchterm}', new CallableRequestHandler(function (Request $request) {
		$args = $request->getAttribute(Router::class);
		return getDBResponse('desc', $args['searchterm']);
	}));

We’ve added routes using the GET method, matching our scheme of “/name/[searchtext]” and “/desc/[searchtext]” and told our router what to do with them: in our case, run the (as of yet unwritten) getDBResponse() function and return the results.

Our server will consist of the IP/port information ($servers), our routing information ($router), and the log info we set up ($logger):

    $server = new Server($servers, $router, $logger);

    yield $server->start();

Notice the use of “yield” in the above $server->start() method. Amp uses yield as “interruption points … allowing other tasks to be run, such as I/O handlers, timers, or other coroutines.” This is part of the whole non-blocking asynchronous stuff that we mentioned at the beginning. Yield “is used to ‘await’ promises, and when a promise is resolved, the coroutine automatically continues.

Finally, we wrap up with the “technically optional” code that makes for a clean closure of resources when we ctrl-c out of our server:

    Amp\Loop::onSignal(SIGINT, function (string $watcherId) use ($server) {
        Amp\Loop::cancel($watcherId);
        yield $server->stop();
    });

That’s it for the web-serving portion of this example. The only thing left is to actually fetch some data.

The Database Block

All this would be for naught if we didn’t take the input search string from the URL and search the database with it:

function getDBResponse($route, $searchTerm) {
	$db = Mysql\pool(Mysql\ConnectionConfig::fromString(
	        "host=".DB_HOST.";user=".DB_USER.";pass=".DB_PASS.";db=".DB_NAME
    ));

	$responseData = "";

	switch ($route) {
        case 'name':
	        $sqlStmt = yield $db->prepare("SELECT * FROM events WHERE LOWER(name) LIKE ?");
	        break;
        case 'desc':
	        $sqlStmt = yield $db->prepare("SELECT * FROM events WHERE LOWER(description) LIKE ?");
	        break;
    }

	$result = yield $sqlStmt->execute(["%" . strtolower($searchTerm) . "%"]);

	while (yield $result->advance()) {
		$row = $result->getCurrent();
		$responseData .= $row['name'] . ',';
	}

	$responseJSON = json_encode($responseData);

	$response = new Response(Status::OK, ['content-type' => 'text/plain'], $responseJSON);

	$db->close();

	return $response;

}

Into this function, we pass what we want to search ($route) and what we’re searching with ($searchTerm). We start by connecting to the database and initializing the field that will contain our response:

	$db = Mysql\pool(Mysql\ConnectionConfig::fromString(
	        "host=".DB_HOST.";user=".DB_USER.";pass=".DB_PASS.";db=".DB_NAME
    ));

	$responseData = "";

A switch statement decides which SQL statement we need to prepare based upon whether we’re searching for a “name” or a “desc”(ription):

	switch ($route) {
        case 'name':
	        $sqlStmt = yield $db->prepare("SELECT * FROM events WHERE LOWER(name) LIKE ?");
	        break;
        case 'desc':
	        $sqlStmt = yield $db->prepare("SELECT * FROM events WHERE LOWER(description) LIKE ?");
	        break;
    }

Earlier when we were adding in the libraries we needed, we included Amp/Mysql. Amp/Mysql is “an asynchronous MySQL client built on the Amp concurrency framework [which] exposes a promise-based API to dynamically query multiple MySQL connections concurrently.” If this seems like overkill for our tutorial, that’s because it is — for a tutorial. If we really wanted to expose some heavy-duty data API code, on the other hand, it could be a critical performance element. So let’s put it to use:

	$result = yield $sqlStmt->execute(["%" . strtolower($searchTerm) . "%"]);

	while (yield $result->advance()) {
		$row = $result->getCurrent();
		$responseData .= $row['name'] . ',';
	}

What we did with Amp/Mysql looks a lot the same as if we were just doing normal MySQL calls, except of course we have “yield”. We iterate the data from our query, and then wrap it up with JSON and return it to our webserver:

	$responseJSON = json_encode($responseData);

	$response = new Response(Status::OK, ['content-type' => 'text/plain'], $responseJSON);

	$db->close();

	return $response;

Our API in action

Seeing the results of our handiwork might seem a little anti-climatic since we don’t have pretty colors, fancy graphics, or fun Javascript widgets on our page:

The default response
Searching the “name” field for “big”
Searching the “description” field for “another”

Undramatic though it might seem, consider what we did: without relying on an Apache or Nginx, we served HTTP content via predefined routes using search criteria… and, even with whitespace, we did it all in less than 100 lines of code.

The full source for this tutorial can be found on Github.

More to explore

Photo by Artem Sapegin on Unsplash

2 Must-Have Tools for WordPress Development

There are some exceptional tools that can be used for WordPress development that make the process way easier than it would be otherwise. I didn’t know about some of these early on in my WordPress development days, so for those of you who might not be familiar with them, I’d like to share.

Photo by Caspar Camille Rubin on Unsplash

Getting WP Post and Postmeta in Single Rows

WordPress uses wp_posts to store post, page, and Custom Post Type (CPT) data the wp_postmeta table for Custom Metabox data. To retrieve this data you have to read for the post plus multiple linked postmeta rows. In this post, we conquer postmeta with subqueries.

Using Redis with WordPress on Ubuntu

Redis is “an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker. It supports data structures such as strings, hashes, lists, sets, sorted sets with range queries, bitmaps, hyperloglogs, geospatial indexes with radius queries and streams.” Translation? It makes websites run faster.

Leave a Comment

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