Query me some Google Books API

Today we continue to build on our SilverStripe project, the book review platform. We will take a first step away from having to write book details manually when we want to review something. We will use the Google Books API to search for books and list the results in a template. Here's what we will cover today:

  • Using the Symfony HTTPClient to query the Google Books API
  • Build a controller and attach a route to it
  • Build a service class to parse a response from the Google Books API
  • Pass values from the controller to a template
  • Using a layout template within a Page-template

While this will take us far we will leave how to actually create new database entries from the API-response for the next entry in this series.

The complete code for this article is available in the GitHub repository devto-book-reviewers:googlebooks

Requests with Symfony HTTPClient

As we are going to consume a RESTful API, we will use a neat library called HTTP Client. It makes it a straight forward task to make HTTP requests and parse the response. We install it by running the following command:

composer require symfony/http-client

Drafting a simple controller

A simple use case for this library is having it run inside a controller and make a GET request to an API:

<?php

namespace App\Controller;

use SilverStripe\CMS\Controllers\ContentController;
use Symfony\Component\HttpClient\HttpClient;

class ReviewController extends ContentController
{
    private static $allowed_actions = [
        'index'
    ];

    public function index()
    {
        $client = HttpClient::create();
        $response = $client->request('GET', 'https://www.googleapis.com/books/v1/volumes?q=isbn:0747532699');
        $data = $response->toArray();

        return $data['items'][0]['volumeInfo']['title'];  // "Harry Potter and the Philosopher's Stone"
    }
}

Connect it with a route

For this controller to be run we need to connect it to a route. We have two options at hand:

  1. We create a ReviewPage that we connect to this controller via getControllerName(), then we instantiate a new page of this type in the admin interface and type the route in the URL field. (This is effectively how we created our Registration page in a previous article.)
  2. We add a route in a config-file and connect it to this controller.

We are going with Option 2 for this project. And here's how we will do it:

Create a new file in ./app/_config/ called routes.yml and add the following:

---
Name: approutes
After: framework/_config/routes#coreroutes
---
SilverStripe\Control\Director:
  rules:
    'review': 'App\Controller\ReviewController'

When we visit /review, we will see Harry Potter and the Philosopher's Stone in the browser. That's great! We know that HTTPClient works and that the controller for this route is working.

Next up, let's see how we can get different outputs when we add a query parameter to the URL (e.g. /review?q=Harry%20Potter). We will need to add some bells and whistles to our controller for this to work. Thanks to SilverStripe, it will not be too hard to do this. We add a new argument to our index() method of a HTTPRequest type and access query parameters on it. If there is a valid query parameter we attach it to our API request.

//...
    public function index(HTTPRequest $request)
    {
        $q = $request->getVar('q');

        if ($q) {
            $client = HttpClient::create();
            $response = $client->request('GET', 'https://www.googleapis.com/books/v1/volumes?q='.$q);
            $data = $response->toArray();

            return $data['items'][0]['volumeInfo']['title'];
        } 

        return "Sorry, no valid query parameter found.";
    }
    //...

In a similar vein we can add a language parameter to our request so we can limit results to a specific language.

public function index(HTTPRequest $request)
    {
        $q = $request->getVar('q');
        $langRestriction = $request->getVar('lang') ? "&langRestrict=" . $request->getVar('lang') : "";

        if ($q) {
            $client = HttpClient::create();
            $response = $client->request('GET', 'https://www.googleapis.com/books/v1/volumes?q='. $q . $langRestriction);
            $data = $response->toArray();

            return $data['items'][0]['volumeInfo']['title'];
        } 

        return "Sorry, no valid query parameter found.";
    }

As we continue to interact more and more with the Google Books API it may be worth to create a service class that can parse each item in the response. This will be useful for when we start to list the search results, and when we want to start to add a review for a specific book. Let's build that service now.

A service in your project

A service is a class that is used to perform a specific task. In this case, we are going to create a service that will parse the response from the Google Books API. We will create a new folder in ./app/src called Service. Inside this folder we will create a new file called GoogleBookParser.php:

./app/src/Service/GoogleBookParser.php
<?php 

namespace App\Service;

use SilverStripe\ORM\ArrayList;

class GoogleBookParser
{
    public static function parse(array $item): array
    {
        $authors = $item['volumeInfo']['authors'] ?? [];

        return [
            'title' => $item['volumeInfo']['title'] ?? '',
            'isbn' => $item['volumeInfo']['industryIdentifiers'][0]['identifier'] ?? '',
            'volumeId' => $item['id'] ?? '',
            'publishedDate' => $item['volumeInfo']['publishedDate'] ?? '',
            'authors' => ($authors ? ArrayList::create(
                array_map(function ($author) {
                    return ['AuthorName' => $author ?? ''];
                }, $item['volumeInfo']['authors'])
            ) : ''),
            'language' => $item['volumeInfo']['language'] ?? '',
            'image' => $item['volumeInfo']['imageLinks']['thumbnail'] ?? '',
            'pageCount' => $item['volumeInfo']['pageCount'] ?? '',
            'categories' => ArrayList::create(
                array_map(function ($category) {
                    return ['CategoryName' => $category ?? ''];
                }, $item['volumeInfo']['categories'] ?? [])
            ),
            'description' => $item['volumeInfo']['description'] ?? '',
        ];
    }

    public static function parseAll(array $response): array
    {
        return array_map(function ($item) {
            return self::parse($item);
        }, $response['items'] ?? []);
    }
}
Enter fullscreen mode Exit fullscreen mode

This class can parse a single item or an array of items. We will use this service in our controller to parse the response from the Google Books API. Here's an example of how:

// Include the service
use App\Service\GoogleBookParser;

// ...
// inside the index-method:
$responseContent = $response->toArray();
$books = GoogleBookParser::parseAll($responseContent);
$book = $books[0];
return $book['title'];
// ...

Passing values from a controller to a template

We are soon going to build a template layout. But before we get there we are going to set our selves up for success by making the book results available to it. We do that by passing values from the controller to the template. While we are at it, we can tell the controller which template we would like to use.

The following controller will take the search-parameters and make a request to the Google Books API. It will then parse the response and pass the results to the template. It will also make a request to small API-endpoint to get a list of languages which can be used to restrict the search. Upon successful response the controller will also pass along an array consisting of pagination information.

./app/src/Controller/ReviewController
<?php

namespace App\Controller;

use App\Service\GoogleBookParser;
use SilverStripe\CMS\Controllers\ContentController;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\ORM\ArrayList;
use Symfony\Component\HttpClient\HttpClient;

class ReviewController extends ContentController
{
    private static $allowed_actions = [
        'index',
    ];

    public function index(HTTPRequest $request)
    {
        $search = $request->getVar('q');
        $searchQuery = "q=" . $search;

        $startIndex = $request->getVar("startIndex") ?? 0;

        $langRestriction = $request->getVar("langRestrict") ?? 'any';
        $langRestrictionQuery = $langRestriction ? "&langRestrict=" . $langRestriction : "";

        $maxResults = $request->getVar('maxResults') ?? 10;
        $maxResultsQuery = '&maxResults=' . $maxResults;

        // Get language codes
        $client = HttpClient::create();
        $response = $client->request('GET', 'https://gist.githubusercontent.com/jrnk/8eb57b065ea0b098d571/raw/936a6f652ebddbe19b1d100a60eedea3652ccca6/ISO-639-1-language.json');
        $languageCodes = [["code" => "any", "name" => "Any"]];
        array_push($languageCodes, ...$response->toArray());


        $books = [];
        $pagination = [];

        if ($search) {
            $basicQuery = $searchQuery 
                        . $langRestrictionQuery
                        . $maxResultsQuery;

            $response = $client->request('GET', 'https://www.googleapis.com/books/v1/volumes?'. $basicQuery);
            $responseContent = $response->toArray();
            $books = GoogleBookParser::parseAll($responseContent);

            $pagination = $this->paginator('/review?' . $basicQuery, $responseContent['totalItems'], $startIndex, $maxResults);
            $pagination['pages'] = ArrayList::create($pagination['pages']);
            $pagination = ArrayList::create([$pagination]);
        } 

        return $this->customise([
            'Layout' => $this
                        ->customise([
                            'Books' => ArrayList::create($books),
                            'Pagination' => $pagination,
                            'Query' => $search,
                            'Languages' => ArrayList::create($languageCodes),
                            'LangRestriction' => $langRestriction
                        ])
                        ->renderWith('Layout/Books'),

        ])->renderWith(['Page']);
    }

    /**
     * Returns an array with links to pages with the necessary query parameters 
     */
    protected function paginator($query, $count, $startIndex, $perPage): array
    {
        $pagination = [
            'start' => false,
            'current' => false,
            'previous' => false,
            'next' => false,
            'totalPages' => 0,
            'pages' => false,
        ];

        $totalPages = ceil($count / $perPage);

        $currentPage = ceil($startIndex / $perPage) + 1;

        $previousIndex = $startIndex - $perPage;
        if ($previousIndex < 0) {
            $previousIndex = false;
        }

        $nextIndex = $perPage * ($currentPage);
        if ($nextIndex > $count) {
            $nextIndex = false;
        }

        $pagination['start'] = [
            'page' => $previousIndex > 0 ? 1 : false,
            'link' => $previousIndex > 0 ? $query . '&startIndex=0' : false,
        ];

        $pagination['current'] = [
            'page' => $currentPage,
            'link' => $query . '&startIndex=' . $startIndex
        ];
        $pagination['previous'] = [
            'page' => $previousIndex !== false ? $currentPage - 1 : false,
            'link' => $previousIndex !== false ? $query . '&startIndex=' . $previousIndex : false,
        ];
        $pagination['next'] = [
            'page' => $nextIndex ? $currentPage + 1 : false,
            'link' => $nextIndex ? $query . '&startIndex=' . $nextIndex : false,
        ];

        $totalPages = ceil($count / $perPage);  
        $pagination['totalPages'] = $totalPages;
        $pages = [];

        for ($i = 0; $i < 3; $i++) {
            $page = $currentPage + $i - 1;

            if ($currentPage == 1) {
                $page = $currentPage + $i;
            }

            if ($page > $totalPages) {
                break;
            }
            if ($page < 1) {
                continue;
            }

            $pages[] = [
                'page' => $page,
                'link' => $query . '&startIndex=' . ($page - 1) * $perPage,
                'currentPage' => $page == $currentPage
            ];
            $pagination['pages'] = $pages;
        } 

        return $pagination;
    }
}
Enter fullscreen mode Exit fullscreen mode

As you may have noted, we are using nested templates in the return statement of the index method. This is because we want to use the same general structure as the rest of the site (which the Page template stands for), and we want finer control over the layout for this particular page (which the Layout/Books template stands for). Within the ->customise method we are passing an array of variables to the template. Each key in this array can be accessed in the template by using the $ prefix. Let's go ahead and build that template now.

Building a layout for our book search

We are going to build a layout for our book search page. We will use the Layout/Books template, which will be included within the Page template. If we look at the Page template, as it is presented in the simple theme, we have the following structure:

<!-- ... -->
<div class="main" role="main">
    <div class="inner typography line">
        $Layout
    </div>
</div>
<!-- ... -->

We are going to build a template that will be injected where $Layout is! Here's how we will do that. Create a new template in the templates/Layout directory called Books.ss. Give it the following structure:

<section class="container">
    <h1 class="text-center">Review a book</h1>

    <% include SearchBar %>

    <div id="Content" class="searchResults">

        <% if $Books %>
            <p class="searchQuery">Results for "$Query"</p>
            <ul id="SearchResults">

                <% loop $Books %>
                    <li>
                        <h4>
                            $title
                        </h4>
                        <div>
                            <% loop $authors %>
                                <p>$AuthorName</p>
                            <% end_loop %>
                        </div>

                        <a class="reviewLink" href="/review/book/{$volumeId}" title="Review &quot;{$title}&quot;">Review &quot;{$title}&quot;...</a>
                    </li>
                <% end_loop %>

            </ul>
            <div id="PageNumbers">
                <div class="pagination">

                    <% loop $Pagination %>
                        <span>

                            <% if $start.link %>
                                <a class="go-to-page" href="$start.link">|<</a>
                            <% end_if %>

                            <% if $previous.link %>
                                <a class="go-to-page" href="$previous.link"><</a>
                            <% end_if %>

                            <% loop $pages %>
                                <% if $currentPage %>
                                    <strong><a class="go-to-page" href="$link">$page</a></strong>
                                <% else %>
                                    <a class="go-to-page" href="$link">$page</a>
                                <% end_if %>
                            <% end_loop %>

                            <% if $next.link %>
                                <a class="go-to-page" href="$next.link">></a>
                            <% end_if %>

                        </span>
                    <% end_loop %>

                </div>
            </div>
        <% end_if %>
    </div>
</section>

This template has an include statement that includes the SearchBar template. This is a partial template that we will create in the templates/Includes directory. Call it SearchBar.ss.

<form class="Actions">
    <div class="line">
        <div class="field">
            <label for="search-input">Search</label>
            <input id="search-input" type="text" name="q" class="text" value="$Query">
        </div>
        <% if $Languages %>
        <div class="field">
            <label for="langRestrict">Language</label>
            <select name="langRestrict" id="langRestrict">

                <% loop $Languages %>
                <option value=$code <% if $Up.LangRestriction == $code %>selected<% end_if %>>$name</option>
                <% end_loop %>

            </select>
        </div>
        <% end_if %>
    </div>
    <div class="line">
        <div class="field">
            <input type="submit" class="btn" value="Search" />
        </div>
    </div>
</form>

We can see the use of some of the variables we passed to the template from the controller. Something is new here however! Why would we need a $Up variable? This is part of the SilverStripe template syntax to access variables that are one step above the current scope. Whenever we are within a loop-structure we are also one step removed from the variables that were passed to the template. LangRestriction is one of the variables that we passed to the template, and Up.LangRestriction is the variable that we can access from within the loop.

While we are on about loops we should also address how we have access to $code and $name variables. These are keys on elements of each of the $Languages array. In the controller we had $languageCodes = [["code" => "any", "name" => "Any"]];. So the first element of the $Languages array would have $code set to any and $name set to Any. It's the same logic that goes into explaining the variable uses in the Books.ss template.

If everything is working correctly, we should now have a working layout for our book search page. We can visit the /review URL and see the results.

22