21
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
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
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"
}
}
For this controller to be run we need to connect it to a route. We have two options at hand:
- 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.) - 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 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'] ?? []);
}
}
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'];
// ...
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;
}
}
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.
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 "{$title}"">Review "{$title}"...</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.
21