Building and Consuming a Read-Only REST API - Part I

AS October 13, 2020
Comments (0)
Building and Consuming a Read-Only REST API - Part I

Extending JReviews these days is easier than ever. In the first part of this development blog post you are going to learn how to use JReviews hooks to build a simple read-only REST API that can can be consumed by external services, or even a mobile app, to display listings and reviews from your site. In the 2nd part of this blog post, I will show you how to create an iOS widget to display listings and introduce you to Alpine JS to create a little web app that communicates with the API.

Before you start, I recommend using the JSON Formatter Chrome extension, which makes JSON easy to read on the browser.

What we are building

The goal is to be able to retrieve listing data in JSON format by simply adding a format=json parameter to any listing list URL. So this will work with category pages, most recent, top rated, custom list and even search result URLs.

The API response will look something like this:

{
  "items": [
    {
      "title": "Listing title",
      "url": "https://domain.com/category/listing",
      "summary": "The listing summary",
      "image": "https://domain.com/path/to/image.jpg",
      "thumbnail": "https://domain.com/path/to/thumb.jpg",
      "created": "2020-10-9 14:33:56",
      "category": {
        "title": "Category"
      }
    },
    {
     ...
    }
  ],
  "pagination": {
    "total": 20,
    "per_page": 10,
    "page": 2,
    "total_pages": 3,
    "links": {
    	"prev": "https://domain.com/most-recent?page=1&format=json",
      	"next": "https://domain.com/most-recent?page=3&format=json"
    }    
  }
}

The `render` filter hook

The typical request to a JReviews is routed to a controller, which prepares the data needed by the view, then invokes a render method to load the corresponding template. So for example, in a page that shows a list of listings, the controller will retrieve the listings from the database and make them available to the view like this:

$this->set(['listings' => $listings]);

The `render` filter allows bypassing the template processing/rendering altogether and instead produce a different output, which is exactly what we want. Rather than processing the template, we want to process the view data directly to build our API response.

This simple example below would override every page, module, widget in JReviews that uses a controller render method and display the "Hello world!" message.

Clickfwd\Hook\Filter::add('render', function($output, $params) {
   return "Hello world!";
});

All the view data is available to the filter within the $params['viewVars'] array.

There are three variations of the `render` filter. They all receive as the first parameter the $output variable, which empty by default, because the template has not been processed at this stage. If the filter returns a non-empty output, then JReviews will use that as the response for the request and will not process the template.

  • `render` - called on every controller request.
  • `render_{name}` - called on every request to a specific controller (e.g. render_categories is invoked on requests to the categories controller).
  • `render_{name}_{action}` - called on every request to a specific controller action (e.g. render_categories_search is invoked on requests to the categories controller search action).

The different filter options are prioritized so the more specific option is processed first, and whichever option returns a non-empty output, that output is used and the rest of the filters are ignored.

Using a PHP class for the API filter

To be able to add a bit more structure to the code, we are going to use a PHP class. The basic structure looks like this:

<?php 
defined('MVC_FRAMEWORK') or die;

use Clickfwd\Hook\Filter;

class JreviewsHookRestApi 
{
	public function __construct() 
    {	
    	// Filter all actions in the categories controller
		Filter::add('render_categories', [$this, 'listings']);
    }

    public function listings($output, $params) 
    {
    	return "Hello world!";
    }
}

new JreviewsHookRestApi();

We add the filter to the constructor to run it automatically when the class is instantiated. The categories controller is where all listing list pages requests get routed in JReviews, so using the render_categories filter automatically limits the filter to run only for requests that get routed to this controller. Once you add the above code to filter_functions.php in overrides and load any listing list page, instead of the listings, you should now see the "Hello world" message.

Running the filter only on API requests

Now we are going to add an isApiRequest method to check if the request is looking for a JSON response. In other words, we only want the render filter to do something if it finds the format=json parameter in the URL. And, at the same time, we are going to add a send method that takes an array and sends a JSON response back to the browser.

We use the format=json parameter because JReviews automatically strips out site template/theme when we use it, and returns just the output from JReviews.

<?php 
defined('MVC_FRAMEWORK') or die;

use Clickfwd\Hook\Filter;

class JreviewsHookRestApi 
{
	public function __construct() 
    {	
    	// Filter all actions in the categories controller
		Filter::add('render_categories', [$this, 'listings']);
    }

    public function listings($output, $params) 
    {
    	if (! $this->isApiRequest()) 
    	{
    		return $output;
    	}

    	return $this->send([
    		"Hello world!"
    	]);
    }

	protected function isApiRequest()
	{
		$request = S2Object::make('request');

		return $request->method() == 'GET' && $request->get('format') == 'json';
	}    

	protected function send($data)
	{
		// Add content type header
		header("Content-type: application/json");

		// Add headers to allow requests from other domains
	  	header("Access-Control-Allow-Origin: *");
	  	header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept");

		return json_encode($data);
	}	
}

new JreviewsHookRestApi();

At this point, if you access any listing list page it should behave normally. However, if you access the same page with the format=JSON parameter, you should see a JSON response with the "Hello world!" message.

https://domain.com/category/path?format=json

[
   "Hello world!"
]

Adding listings to the response

Everything is ready to start processing the listings data and build a simple API response. Below we expand the existing `listings` method and create a new re-usable`listingsResource` method that generates the output for each listing to add it to the `items` array.

public function listings($output, $params) 
{
	if (! $this->isApiRequest()) 
	{
		return $output;
	}

    // Only continue if 'listings' were sent to the view
	if (! isset($params['viewVars']['listings'])) 
	{
		return $output;
	}

	// List of listings for the current page
	$listings = $params['viewVars']['listings'] ?: [];

	$items = [];

	foreach ($listings as $listing)
	{
		$items[] = $this->listingResource($listing);
	}

	return $this->send([
		'items' => $items,
	]);
}

protected function listingResource($listing)
{
	$url = cmsFramework::makeAbsUrl(cmsFramework::route($listing['Listing']['url']));

	return [
		'title' => $listing['Listing']['title'],
		'url' => $url,
		'summary' => $listing['Listing']['summary'],
		'created' => $listing['Listing']['created'],
	];
}

If you load the same page you used to test before, now you should see the JSON output including the list of listings for the page. At this time we have just a few data points for the listing. Lets extend this quickly by including image, thumbnail and category information for each listing, and we are going to use additional resource methods for each one.

public function listings($output, $params) 
{
	if (! $this->isApiRequest()) 
	{
		return $output;
	}

    // Only continue if 'listings' were sent to the view
	if (! isset($params['viewVars']['listings'])) 
	{
		return $output;
	}

	// List of listings for the current page
	$listings = $params['viewVars']['listings'] ?: [];

	$items = [];

	foreach ($listings as $listing)
	{
		$items[] = $this->listingResource($listing);
	}

	return $this->send([
		'items' => $items,
	]);
}

protected function listingResource($listing)
{
	$url = cmsFramework::makeAbsUrl(cmsFramework::route($listing['Listing']['url']));

	return [
		'title' => $listing['Listing']['title'],
		'url' => $url,
		'summary' => $listing['Listing']['summary'],
		'created' => $listing['Listing']['created'],
		'image' => $this->imageResource($listing),
		'thumbnail' => $this->thumbnailResource($listing),
		'category' => $this->categoryResource($listing),
	];
}

protected function imageResource($item)
{
	$image = $item['MainMedia']['media_url'] ?? $item['Media']['photo'][0] ?? null;

	$categoryImage = !empty($item['MainMedia']['category'])
						? cmsFramework::makeAbsUrl($item['MainMedia']['category'])
						: null;

	return $image ?? $categoryImage ?? null;
}

protected function thumbnailResource($item)
{
	return $item['MainMedia']['media_info']['thumbnail']['640x640s']['url'] 
			?? $this->imageResource($item);
}

protected function categoryResource($item)
{
	return [
		'title' => $item['Category']['title'],
	];
}

Adding pagination to the API response

Next up, pagination. It's useful to have pagination information in API responses to make it easier for the service consuming the API to know the total number of items, items per page, and to simplify the logic for retrieving the items for next and previous pages. We want to add a pagination object to the response that looks like this:

{
  "items": [...], // 10 items
  "pagination": {
    "total": 12,
    "per_page": 10,
    "page": 1,
    "total_pages": 2,
    "links": {
      "next": "https://domain.com/category/path?format=json&page=2"
    }
  }
}

To do that we will modify the `listings` method add the pagination key and create a few additional pagination methods to keep the code organized and easy to read. When a list page is rendered in JReviews, the controller is sending some pagination data to the view, which includes the number of pages, the items per page, and the current page. We can use this for our response and to generate the previous and next links where appropriate.

public function listings($output, $params) 
{
	// existing code omitted for brevity ...


	// pagination key added to the response 
	return $this->send([
		'items' => $items,
		'pagination' => $this->pagination($params['viewVars']['pagination']),
	]);
}

protected function pagination($vars)
{
	$total = $vars['total'];

	$per_page = $vars['limit'];

	$page = ((int) $vars['page']) ?: 1;

	$total_pages = ceil($total/$per_page);

	return [
		'total' => (int) $total,
		'per_page' => $per_page,
		'page' => $page,
		'total_pages' => $total_pages,
		'links' => $this->paginationPreviousNextLinks($page, $total_pages),
	];
}	

protected function paginationPreviousNextLinks($page, $pages)
{
	$url = cmsFramework::makeAbsUrl(cmsFramework::getCurrentUrl());

	$urlParts = parse_url($url);

	$links = [];

	if ($page > 1 && $pages > 1) 
	{
		$links['previous'] = $this->pageLink($urlParts, $page - 1);
	}

	if ($page < $pages && $pages > 1) 
	{
		$links['next'] = $this->pageLink($urlParts, $page + 1);
	}

	return $links;		
}

protected function pageLink($urlParts, $page)
{
	parse_str($urlParts['query'] ?? '', $queryString);
	
	$queryString['format'] = 'json';

	$baseUrl = $urlParts['scheme'].'://'.$urlParts['host'].$urlParts['path'];

	return $baseUrl.'?'.http_build_query(array_merge($queryString,[S2_QVAR_PAGE => $page]));
}

Putting It All Together

We now have a simple JSON REST API for lists of listings. And what's more, it automatically works for all the listing list pages and search result URLs on your site. The same approach can be used to generate responses for reviews, listing detail pages, and more. You can find the entire code below, and as a BONUS, it also includes a filter for the `render_reviews` and the `reviews` method so you can also get a JSON response on the pages that display lists of reviews (e.g. latest user reviews, etc.). You can also grab the code from this Gist.

<?php
defined('MVC_FRAMEWORK') or die;

use Clickfwd\Hook\Filter;

class JreviewsHookRestApi 
{
	protected $routes;

	public function __construct()
	{
		Filter::add('render_reviews', [$this, 'reviews']);

		Filter::add('render_categories', [$this, 'listings']);
	}

	public function listings($output, $params) 
	{
		if (! $this->isApiRequest()) {
			return $output;
		}

		// Only continue if 'listings' were sent to the view
		if (! isset($params['viewVars']['listings'])) 
		{
			return $output;
		}

		// List of listings for the current page
		$listings = $params['viewVars']['listings'] ?? [];

		$items = [];

		foreach ($listings as $listing)
		{
			$items[] = $this->listingResource($listing);
		}

		return $this->send([
			'items' => $items,
			'pagination' => $this->pagination($params['viewVars']['pagination']),
		]);
	}

	public function	reviews($output, $params) 
	{
		if (! $this->isApiRequest()) {
			return $output;
		}
	
		// Only continue if 'reviews' were sent to the view
		if (! isset($params['viewVars']['reviews'])) 
		{
			return $output;
		}

		S2App::import('Helper','routes','jreviews');

		$this->routes = ClassRegistry::getClass('RoutesHelper');

		// List of reviews for the current page
		$reviews = $params['viewVars']['reviews'] ?? [];

		$items = [];

		foreach ($reviews as $review)
		{
			$items[] = $this->reviewResource($review);
		}

		return $this->send([
			'items' => $items,
			'pagination' => $this->pagination($params['viewVars']['pagination']),
		]);
	}

	protected function isApiRequest()
	{
		$request = S2Object::make('request');

		return $request->method() == 'GET' && $request->get('format') == 'json';
	}

	protected function listingResource($listing)
	{
		$url = cmsFramework::makeAbsUrl(cmsFramework::route($listing['Listing']['url']));

		return [
			'title' => $listing['Listing']['title'],
			'href' => $url,
			'summary' => $listing['Listing']['summary'] ?? null,
			'image' => $this->imageResource($listing),
			'thumbnail' => $this->thumbnailResource($listing),
			'created' => $listing['Listing']['created'] ?? null,
			'category' => $this->categoryResource($listing)
		];
	}

	protected function reviewResource($review)
	{
		$url = $this->routes->reviewDiscuss('',$review,['listing'=>$review,'return_url'=>true]);

		return [
			'title' => $review['Review']['title'],
			'href' => $url,
			'comment' => $review['Review']['comments'],
			'average_rating' => (float) ($review['Rating']['average_rating'] ?? null),
			'created' => $review['Review']['created'],
			'listing' => $this->listingResource($review),
		];		
	}

	protected function imageResource($item)
	{
		$image = $item['MainMedia']['media_url'] ?? $item['Media']['photo'][0] ?? null;

		$categoryImage = !empty($item['MainMedia']['category'])
							? cmsFramework::makeAbsUrl($item['MainMedia']['category'])
							: null;

		return $image['media_url'] ?? $categoryImage ?? null;
	}

	protected function thumbnailResource($item)
	{
		return $item['MainMedia']['media_info']['thumbnail']['640x640s']['url'] 
				?? $this->imageResource($item);
	}

	protected function categoryResource($item)
	{
		return [
			'title' => $item['Category']['title'],
		];
	}

	protected function pagination($vars)
	{
		$total = $vars['total'];

		$per_page = $vars['limit'];

		$page = $vars['page'] !== '' ? $vars['page'] : 1;

		$total_pages = ceil($total/$per_page);

		return [
			'total' => (int) $total,
			'per_page' => $per_page,
			'page' => $page,
			'total_pages' => $total_pages,
			'links' => $this->paginationPreviousNextLinks($page, $total_pages),
		];
	}	

	protected function paginationPreviousNextLinks($page, $pages)
	{
		$url = cmsFramework::makeAbsUrl(cmsFramework::getCurrentUrl());

		$urlParts = parse_url($url);

		$links = [];

		if ($page > 1 && $pages > 1) 
		{
			$links['previous'] = $this->pageLink($urlParts, $page - 1);
		}

		if ($page < $pages && $pages > 1) 
		{
			$links['next'] = $this->pageLink($urlParts, $page + 1);
		}

		return $links;		
	}

	protected function pageLink($urlParts, $page)
	{
		parse_str($urlParts['query'] ?? '', $queryString);
		
		$queryString['format'] = 'json';

		$baseUrl = $urlParts['scheme'].'://'.$urlParts['host'].$urlParts['path'];

		return $baseUrl.'?'.http_build_query(array_merge($queryString,[S2_QVAR_PAGE => $page]));
	}

	protected function send($data)
	{
		header("Content-type: application/json");
		header("Access-Control-Allow-Origin: *");
		header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept");

		return json_encode($data);
	}	
}

new JreviewsHookRestApi();

The second part of this blog post will explore a couple of different ways to consume this API.

User comments

There are no user comments for this listing.
To write a comment please register or