Query me some Google Books API
Tags: php, silverstripe, symfony
Published: 2022-01-06 08:25:04

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](https://developers.google.com/books/docs/overview) 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](https://github.com/andersbjorkland/devto-book-reviewers/tree/googlebooks)

## Requests with Symfony HTTPClient
As we are going to consume a RESTful API, we will use a neat library called [HTTP Client](https://packagist.org/packages/symfony/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:  
```bash
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
<?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:  
```yaml	
---
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.

```php	
    //...
    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. 
```php 
    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`:  
{% details ./app/src/Service/GoogleBookParser.php %}
```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'] ?? []);
    }
}
```
{% enddetails %}

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:  
```php

// 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.  

{% details ./app/src/Controller/ReviewController %}
```php
<?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;
    }
}
```
{% enddetails %}

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:
```php
<!-- ... -->
<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:
```twig
<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`.
```twig
<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.

![Search for a book and list the results with pagination](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jatxz3pedkbtgomsaxq7.gif)