Consuming JSON APIs in Drupal 8

Photo of kittens looking at JSON and Drupal logos

Nowadays everyone has an API and it's fairly common to want a website you're working on to fetch data from a 3rd party API. That's because pulling 3rd party data into your website can not only enriches your website's content, but doing so can prevent the need to duplicate commonly needed data.

API provided data could include displaying weather information, going through drupal.org projects, looking through census results, or even displaying Magic the Gathering card data. In fact, every WordPress site comes with an active JSON API out of the box.

There really is an API for almost anything. It's no surprise that you'll eventually want to consume some random API while developing a Drupal website. Enough of the sales pitch, let's get started consuming JSON APIs.

The Plan:

  1. Look at what it takes to fetch and consume JSON data in general.
  2. Explore the popular Guzzle PHP library.
  3. Create a Drupal 8 module that consumes an API and displays the data on your website.

Seems pretty straightforward huh? I think so too, but before we go any further let's define some of the terms that appear throughout this post.

  • API - Application Programming Interface. Literally, "a thing developers can use to interact with another program".
  • Request - An interaction with a web API. Very similar to visiting a website in your browser. When you visit a website in your browser, you are making a "request" to the website's server.
  • Base URI - Part of a URL that is the root of a web API request. This tends to be a domain name such as "api.mywebsite.com", but can also include a path such as "mywebsite.com/api".
  • Endpoint - Part of the URL for an API request that usually defines what type of data you are requesting. For example, one of the most common WordPress API endpoints is "posts", which retrieves Posts from the WordPress website.
  • Query Parameters - Part of the URL for an API request that further describes the specific data you are requesting. Query parameters look like key-value pairs within the URL for the API request.

Here is an example on an API request URL:
http://demo.wp-api.org/wp-json/wp/v2/posts?per_page=2

And here is how that API request URL breaks down into the terms defined above:

  • Base URI - http://demo.wp-api.org/wp-json/wp/v2/
  • Endpoint - posts
  • Query Parameters - ?per_page=2

Hopefully that helps clarify some of the terminology used throughout the rest of this post.

Getting Started: Fetching JSON in PHP

The first thing we want to look at are the basics of getting data from an API. We need the following things:

  1. A public API that will provide us with data.
  2. A way to visit the API and get its data.
  3. A method of converting the raw data the API gives us into an array so we can easily process and output it as we see fit.

Broken down into their tiniest pieces, each of the above needs are fairly straightforward. Out of the box, PHP provides us with a function that handles both visiting an API and getting its data in file_get_contents(), as well as another function for converting that raw string data into an array in json_decode().

As for the public API… I'd like to introduce you to Cat Facts, a public API that will provide us with all the facts about cats we could ever want!

Let's put all these pieces together and write a simple PHP script that will consume the Cat Facts JSON data.

<?php
$data = file_get_contents('https://cat-fact.herokuapp.com/facts/random?amount=2');
$cat_facts = json_decode($data, TRUE);

foreach ($cat_facts as $cat_fact) {
  print "<h3>".$cat_fact['text']."</h3>";
}

Result:

There we go. Now we can use this technique to get as many random Cat Facts as we want (up to 500 per request). But before we continue, let's break down this script a bit and see what it's doing.

  1. First we use file_get_contents() to request the data from the API.
    (The response data comes back to us in the form of a string.)
  2. Next we convert it into an Array using the json_decode() function.
  3. Now that the data is an array, we can loop through it and output all of our brand new cat facts. Outstanding!

Note: I didn't make up the URL shown in this example, I read the documentation. You can visit that API request URL directly and see what the response data looks like. Go ahead, I'll wait here…

… Welcome back!

Check this out: Most APIs you interact with will have its own documentation that you should use when planning your project. If you find yourself struggling to figure out how to get data from an API, look for more documentation. Documentation is king when dealing with APIs. The better the documentation, the better the API in my opinion.

Object Oriented API Requests with Guzzle

Now that we have a decent understanding of the main points of requesting data from an API, let's take a look at how we might do that in a more practical and modern way. Let's use the very popular PHP HTTP client named Guzzle to do basically the same thing we just did above.

The main differences in using Guzzle are mostly around the abstractions provided by Guzzle's Client library. Rather than trying to describe each difference out of context, let's look at an example:

<?php
require 'vendor/autoload.php';

$client = new \GuzzleHttp\Client([
  'base_uri' => 'https://cat-fact.herokuapp.com/',
]);

$response = $client->get('facts/random', [
  'query' => [
    'amount' => 2,
  ]
]);

$cat_facts = json_decode($response->getBody(), TRUE);

foreach ($cat_facts as $cat_fact) {
  print "<h3>".$cat_fact['text']."</h3>";
}

Let's paws for a moment and review what has changed and why:

  1. The first thing to note is that we've created a new instance of Guzzle's Client object and passed in some parameters as an array. Rather than provide the entire URI for the API's endpoint along with query parameters in one string like before ('https://cat-fact.herokuapp.com/facts/random?amount=2'), we will instantiate the client with just the base URI for the API in general. This way we can easily make multiple requests to multiple endpoints with the same Client object.
  2. Next, we use the Client's get() method to request a specific endpoint of 'facts/random'. Internally Guzzle will combine the endpoint with the base_uri we provided during object instantiation.
  3. Additionally, we provide an array of query parameters to the get() method. Internally Guzzle will convert this array into a query string that looks like this '?amount=2' and append it to the URL before submitting the request to the API.
  4. Unlike file_get_contents(), the Guzzle client returns a Response object. This object contains much more information about the reply from the API, as well as the contents of the response.
  5. Finally, we access the contents of the response by using the getBody() method on the Response object.

It may seem like a lot has changed from our first example, but I highly recommend becoming more comfortable with this approach. Not only is it significantly more powerful and flexible than the file_get_contents() approach, but also because Drupal 8 uses the Guzzle library.

Ultimately, both this and the previous example are doing the exact same things. They are both visiting the Cat Facts API and fetching data.

Now that we are comfortable with requesting data from an API (aka, visiting a URL), I think we're ready to do this in Drupal 8.

Guzzle in Drupal 8

The plan is simple; create a Drupal 8 module that fetches cat facts from the Cat Facts API and displays those facts in a Block.

There are a few ways to accomplish this, but we'll start with the most basic. Have a look at this custom Block:

<?php

namespace Drupal\cat_facts\Plugin\Block;

use Drupal\Component\Serialization\Json;
use Drupal\Core\Block\BlockBase;

/**
 * Block of Cat Facts... you can't make this stuff up.
 *
 * @Block(
 *   id = "cat_facts_block",
 *   admin_label = @Translation("Cat Facts")
 * )
 */
class CatFacts extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function build() {
    /** @var \GuzzleHttp\Client $client */
    $client = \Drupal::service('http_client_factory')->fromOptions([
      'base_uri' => 'https://cat-fact.herokuapp.com/',
    ]);

    $response = $client->get('facts/random', [
      'query' => [
        'amount' => 2,
      ]
    ]);

    $cat_facts = Json::decode($response->getBody());
    $items = [];

    foreach ($cat_facts as $cat_fact) {
      $items[] = $cat_fact['text'];
    }

    return [
      '#theme' => 'item_list',
      '#items' => $items,
    ];
  }

}

Block Output:

At first this looks like a lot more code, but most of the new stuff is the code necessary to create a Drupal 8 block. Rather than focus on that, let's look at the important difference between this and the previous example.

  1. Drupal core provides a service designed to create HTTP clients: 'http_client_factory'. This service has a method named fromOptions() which accepts an array, just like the Guzzle Client constructor did before. We even passed in the exact same parameter.
  2. Instead of calling json_decode() function, we use the Drupal provided Json::decode() method.

Note: We could have instantiated our own Guzzle Client object, but the above approach has the following added benefits:

  • First, Drupal is going to merge our options array into a set of its own options that provide some HTTP request best practices.
  • Second, using this method (somewhat) future proofs us from major changes in the Guzzle library. For example, if Drupal improves its http_client_factory service, or even decides to switch libraries all together, we'd like to believe that the core developers will take on the burden of ensuring the http_client_factory service still works the way we expect.
  • The same reasoning applies to using the Json::decode() method Drupal provides as opposed to the json_decode() function.

Generally, whenever Drupal provides a way to do something, you should use the Drupal way.

Now this is all very good, we're making API requests the "Drupal Way" and we can place it almost anywhere on our site with this handy block.

But, I know what you're thinking. You're thinking, "Jonathan, Cat Facts shouldn't be contained to just a block. What if I want to use cat facts somewhere else on the site? Or what if another contributed module wants to make use of these awesome Cat Facts in their own module?"

And you're totally right to be thinking that. Hence...

Cat Facts as a Service (CFaaS)

There is no better way to make Cat Facts available to other non-block parts of your site and other contributed modules than to provide a Cat Fact service in our own module. I can see it now, with such a powerful feature our Cat Facts module will soon be a dependency of almost every other popular Drupal 8 module.

First thing we need to do is define our new Cat Facts service in our module's services file.

cat_facts.services.yml

services:
  cat_facts_client:
    class: Drupal\cat_facts\CatFactsClient
    arguments:
      - '@http_client_factory'

Purrfect.

Next, we need to write this new CatFactsClient class. It will look relatively similar to the work we've done already; but, this time instead of calling the Drupal::service() method, we'll use dependency injection to provide our CatFactsClient class with the http_client_factory core service automatically.

src/CatFactsClient.php

<?php

namespace Drupal\cat_facts;

use Drupal\Component\Serialization\Json;

class CatFactsClient {

  /**
   * @var \GuzzleHttp\Client
   */
  protected $client;

  /**
   * CatFactsClient constructor.
   *
   * @param $http_client_factory \Drupal\Core\Http\ClientFactory
   */
  public function __construct($http_client_factory) {
    $this->client = $http_client_factory->fromOptions([
      'base_uri' => 'https://cat-fact.herokuapp.com/',
    ]);
  }

  /**
   * Get some random cat facts.
   *
   * @param int $amount
   *
   * @return array
   */
  public function random($amount = 1) {
    $response = $this->client->get('facts/random', [
      'query' => [
        'amount' => $amount
      ]
    ]);

    return Json::decode($response->getBody());
  }

}

What we've done here is create a simple class that focuses solely on the Cat Facts API. It abstracts most of the work you would normally have to do when you need to request data from the API by providing a method named random(). This method performs the HTTP request and return a decoded array of data back to the caller.

Finally, all we need to do is update our Cat Facts block to allow our new service to be injected into it as a dependency.

src/Plugin/Block/CatFacts.php


<?php

namespace Drupal\cat_facts\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Block of Cat Facts... you can't make this stuff up.
 *
 * @Block(
 *   id = "cat_facts_block",
 *   admin_label = @Translation("Cat Facts")
 * )
 */
class CatFacts extends BlockBase implements ContainerFactoryPluginInterface {

  /**
   * @var \Drupal\cat_facts\CatFactsClient
   */
  protected $catFactsClient;

  /**
   * CatFacts constructor.
   *
   * @param array $configuration
   * @param $plugin_id
   * @param $plugin_definition
   * @param $cat_facts_client \Drupal\cat_facts\CatFactsClient
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, $cat_facts_client) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->catFactsClient = $cat_facts_client;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('cat_facts_client')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function build() {
    $cat_facts = $this->catFactsClient->random(2);
    $items = [];

    foreach ($cat_facts as $cat_fact) {
      $items[] = $cat_fact['text'];
    }

    return [
      '#theme' => 'item_list',
      '#items' => $items,
    ];
  }

}

And there we have it! Now our block has the cat_facts_client service injected into it during creation. And rather than the block making its own Guzzle Client and API calls, it uses our shiny new service.

Ya feline it?

View module code on GitHub.