Using JSON API to query your Search API indexes

Published onSaturday 28, October 2017

The JSON API module is becoming wildly popular for in Drupal 8 as an out of the box way to provide an API server. Why? Because it implements the {json:api} specification. It’s still a RESTful interface, but the specification just helps bring an open standard for how data should be represented and requests should be constructed. The JSON API module exposes collection routes, which allows retrieving multiple resources in a single request. You can also pass filters to restrict the resources returned.

An example would be “give me all blog posts that are published.”

GET /jsonapi/node/article?filter[status][value]=1

But, what about complex queries? What if we want to query for content based on keywords? Collections are regular entity queries, which means they are as strong as any SQL query. If you wanted to search for blogs of a certain title or body text, you could do the following. Let’s say we searched for “Drupal” blogs

GET /jsonapi/node/article?
filter[title-filter][condition][path]=title
filter[title-filter][condition][operator]=CONTAINS
filter[title-filter][condition][value]=Drupal
filter[body-filter][condition][path]=title
filter[body-filter][condition][operator]=CONTAINS
filter[body-filter][condition][value]=Drupal

This could work. However, it starts to break down on complex queries and use cases - outside of searching for a blog.

Providing a collection powered by Search API

I have a personal project that I’ve been slowly working on which requires searching for food. It combines text, geolocation, and ratings for returning the best results. The go-to solution for returning this kind of result data is an Apache Solr search index. The Search API module makes this a breeze to index Drupal entities and index them with this information. My issue was retrieving the data over a RESTful interface. I wanted a decoupled Drupal instance so the app could eventually support native applications on iOS or Android. Also, because it sounded fun.

I was able to create a new resource collection which used my Solr index as its data source. There are some gotchas, however. At the time of when I first wrote this code (a few months ago), I had to manually support query filtering since the properties did not exist as real fields inside of Drupal. In my example, I don’t really adhere to the JSON API specification, but it worked and it’s not finished.

Here is the routing.yml within my module

forkdin_api.search:
  path: '/api/search'
  defaults:
    _controller: '\Drupal\forkdin_api\Controller\Search::search'
  requirements:
    _entity_type: 'restaurant_menu_item'
    _bundle: 'restaurant_menu_item'
    _access: 'TRUE'
    _method: GET
  options:
    _auth:
      - cookie
      -   always
    _is_jsonapi: 1

My Search API index has one data source: the restaurant menu item. JSON API collection routes expect there to be an entity type and bundle type in order to re-use the existing functionality. All JSON API routes are also flagged as _is_jsonapi to get this fanciness.

The following is the route controller. All that is required is that we return a ResourceResponse which contains an EntityCollection object, made from all of the entity resources to be returned. This controller takes the filters passed and runs an index query through Search API. I load the entities from the result and pass them into a collection to be returned. Since I’m not re-using a JSON API controller I made sure to add url.query_args:filter as a cacheable dependency for my response.

<?php

namespace Drupal\forkdin_api\Controller;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\jsonapi\Resource\EntityCollection;
use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
use Drupal\jsonapi\ResourceResponse;
use Drupal\jsonapi\Routing\Param\Filter;
use Drupal\search_api\ParseMode\ParseModePluginManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

class Search extends ControllerBase {

  /**
   * @var \Drupal\search_api\IndexInterface
   */
  protected $index;

  /**
   * The parse mode manager.
   *
   * @var \Drupal\search_api\ParseMode\ParseModePluginManager|null
   */
  protected $parseModeManager;

  public function __construct(EntityTypeManagerInterface $entity_type_manager, ParseModePluginManager $parse_mode_manager) {
    $this->entityTypeManager = $entity_type_manager;
    $this->index = $entity_type_manager->getStorage('search_api_index')->load('food');
    $this->parseModeManager = $parse_mode_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity_type.manager'),
      $container->get('plugin.manager.search_api.parse_mode')
    );
  }

  public function search(Request $request) {
    $latitude = NULL;
    $longitude = NULL;
    $fulltext = NULL;

    // @todo move to a Filter object less strict than `jsonapi` one
    //       this breaks due to entity field defnitions, etc.
    $filters = $request->query->get('filter');
    if ($filters) {
      $location = $filters['location'];
      if (empty($location)) {
        // @todo look up proper error.
        return new ResourceResponse(['message' => 'No data provided']);
      }

      // @todo: breaking API, because two conditions under same type?
      $latitude = $location['condition']['lat'];
      $longitude = $location['condition']['lon'];

      if (isset($filters['fulltext'])) {
        $fulltext = $filters['fulltext']['condition']['fulltext'];
      }
    }
    $page = 1;

    $query = $this->index->query();
    $parse_mode = $this->parseModeManager->createInstance('terms');
    $query->setParseMode($parse_mode);

    if (!empty($fulltext)) {
      $query->keys([$fulltext]);
    }

    $conditions = $query->createConditionGroup();
    if (!empty($conditions->getConditions())) {
      $query->addConditionGroup($conditions);
    }
    $location_options = (array) $query->getOption('search_api_location', []);
    $location_options[] = [
      'field' => 'latlon',
      'lat' => $latitude,
      'lon' => $longitude,
      'radius' => '8.04672',
    ];
    $query->setOption('search_api_location', $location_options);
    $query->range(($page * 20), 20);
    /** @var \Drupal\search_api\Query\ResultSetInterface $result_set */
    $result_set = $query->execute();

    $entities = [];
    // @todo are the already loaded, or can be moved to get IDs then ::loadMultiple
    foreach ($result_set->getResultItems() as $item) {
      $entities[] = $item->getOriginalObject()->getValue();
    }

    $entity_collection = new EntityCollection($entities);
    $response = new ResourceResponse(new JsonApiDocumentTopLevel($entity_collection), 200, []);
    $cacheable_metadata = new CacheableMetadata();
    $cacheable_metadata->setCacheContexts([
      'url.query_args',
      'url.query_args:filter',
    ]);
    $response->addCacheableDependency($cacheable_metadata);
    return $response;
  }

}

The request looks like

GET /api/search?
filter[fulltext][condition][fulltext]=
filter[location][condition][lat]=42.5847425
filter[location][condition][lon]=-87.8211854
include=restaurant_id

With an example result:

Results