Group and Nodeaccess - an efficient combination
Blogi

RESTful, Group and Nodeaccess - an efficient combination (part 4/4)

George Baev
George Baev

In the final part of our Drupal Decoupled System blog series, we describe the Drupal Elasticsearch related APIs which are used by the front end React application.

Hi again. I am George Baev, Drupal developer at Sangre, and this is the final post in our blog series describing the move from a Monolith to a Decoupled System in Drupal. In my previous post, I described the Drupal REST API functionality, PHP Traits to check the user's permissions, creating a compound response data structure and the Diagram CRUD operations. In this final post, I will describe the Drupal Elasticsearch related APIs which are used by the front end React application.

Ingestion API

The Elasticsearch service stores the Markers-related data. The data in this service is modified by the Ingestion API.

The Marker create request can be executed as follows:

curl -X POST \
  https://localhost/api/v1.0/markers \
  -H 'Content-Type: application/json' \
  -H 'X-CSRF-Token: token' \
  -d '[
  {
    "group_id": "0",
    "language": "en",
    "user_id": "1",
    "visibility": "PUBLIC",
    "title": "Test Marker",
    "body": "Test Body"
  }
]'

As seen from the command described above, the marker data is sent in the JSON format.

This POST request is processed by the following class method:

/**
  * {@inheritdoc}
  */
public function create($object) {
  $this->validateBody($object);

  module_load_include('inc', 'uuid', 'uuid');

  foreach ($object as $o) {
    // Check the Group only if the Group is not Public, i.e group_id > 0.
    if (is_numeric($o['group_id']) && $o['group_id'] > 0) {
      $this->checkGroupPermission($o['group_id'], self::PERMISSION);
    }

    $uuid = !empty($o['uuid']) ? $o['uuid'] : uuid_generate();

    $values = [
      'type' => 'marker',
      'uid' => $o['user_id'],
      'status' => NODE_PUBLISHED,
      'comment' => COMMENT_NODE_OPEN,
      'promote' => NODE_NOT_PROMOTED,
    ];

    $entity = entity_create('node', $values);

    $wrapper = entity_metadata_wrapper('node', $entity);
    $wrapper->title->set($uuid);
    $wrapper->save();

    $o['original_id'] = intval($wrapper->getIdentifier());
    $o['owner'] = $o['user_id'];
    $o['uuid'] = $uuid;

    $ingestion_data[] = $this->setValues($o);
  }

  $options = [
    'method' => 'POST',
    'data' => drupal_json_encode($ingestion_data),
  ];

  $this->doApiCall('markers', $options);

  foreach ($ingestion_data as $d) {
    $out['markers'][] = [
      'uuid' => $d['uuid'],
      'nid' => $d['original_id'],
    ];
  }

  return $out;
}

This method creates a Drupal connector node which references the marker in Elasticsearch. This connector node does not have any custom fields. Its title value is set to the UUID value assigned to the marker. This way, the connector links the marker in Elasticsearch with its user reactions such as votes and ratings in Drupal.

The doApiCall method is the one which executes the request to the Ingestion API. Its functionality is described below:

/**
  * Executes the Ingestion API endpoint call.
  *
  * @param string $endpoint
  *    The API endpoint.
  * @param array $options
  *    The request options.
  *
  * @return array
  *    The response data.
  */
protected function doApiCall($endpoint, $options) {
  $url_variable = "my_ingestion_api_url";
  $token_variable = "my_ingestion_api_token";

  $api_url = variable_get($url_variable, FALSE);
  $api_token = variable_get($token_variable, FALSE);
  if (empty($api_url) || empty($api_token)) {
    throw new BadRequestException("The ({$url_variable}) and ({$token_variable}) variables must be set!");
  }

  $options['headers'] = [
    'Content-Type' => 'application/json',
    'Authorization' => "Basic $api_token",
  ];

  $api_url .= $endpoint;

  $response = drupal_http_request($api_url, $options);

  if (!empty($response->error)) {
    throw new BadRequestException("Ingestion API ($endpoint): {$response->error}");
  }

  return $response->data;
}

As seen, the versatile drupal_http_request function is used to send the data to Elasticsearch. The custom BadRequestException is thrown in case of an error and the response's data is returned on success.

Search API

The Search API is used to retrieve the Markers data from Elasticsearch. The HTTP GEt request is similar to this one:

curl -X GET \
  https://localhost/search/v1/marker/<marker-uuid> \

This request is processed by the following functionality:

/**
 * Executes a Search API call.
 *
 * @param array $uuid
 *    The marker UUID.
 * @param string $group
 *    The marker group.
 * @param array $fields
 *    The requested fields.
 *
 * @return mixed
 *    The response data or FALSE on error.
 */
function my_module_search_api($uuid, $group, $fields = []) {
  $search_api_url = variable_get('my_search_api_url', FALSE);

  if (empty($search_api_url) || !is_array($uuid)) {
    return FALSE;
  }

  $options = [
    'headers' => [
      'X-Internal-IP' => '127.0.0.1',
      'Content-Type' => 'application/json',
    ],
    'method' => 'POST',
  ];

  // Get all fields if none were specified.
  if (empty($fields)) {
    $fields = [
      // The list of fields to include in the response.
    ];
  }

  $url_fields = drupal_http_build_query(['fields' => implode(',', $fields)]);

  $options['data'] = drupal_json_encode([
    'group' => $group,
    'markers' => $uuid,
  ]);

  $response = drupal_http_request($search_api_url . '?' . $url_fields, $options);

  $data = drupal_json_decode($response->data);

  if (!empty($data['error'])) {
    watchdog('my_module', 'Search API error: @err', ['@err' => $data['message']]);
    return FALSE;
  }

  if (!empty($data['result'])) {
    $out = array_merge($data['result'], $out);
  }

  return $out;
}

Similar to the Ingestion API, the same drupal_http_request function is used to connect to the Elasticsearch. This approach allows dynamic inclusion of fields in the response and reducing the response size. The Search API is accessed both from React and Drupal as well as any external client application.

Auth API

The Authentication API grants access to the content in Elasticsearch. Each Search API request is authorized using the Auth API. The Elasticsearch services send requests to this API with the client's credentials.

The Auth-related functionality is the following:

/**
  * {@inheritDoc}
  */
public function process() {
  $request = $this->getRequest();
  $method = $request->getMethod();
  $path = $request->getPath();
  $out = [];

  switch ($method) {
    case RequestInterface::METHOD_GET:
      $headers = $this->getRequest()->getHeaders()->getValues();
      if (!isset($headers['authorization'])) {
        throw new BadRequestException('The Authorization parameter must be set in the Request!', 401);
      }

      /* @var \Drupal\restful\Http\HttpHeader */
      $auth_header = $headers['authorization'];

      $auth_value = $auth_header->get()[0];
      if (empty($auth_value)) {
        throw new BadRequestException('The Authorization parameter value must be set in the Request!', 401);
      }

      $token = array_pop(explode(' ', $auth_value));

      if (empty($token)) {
        throw new BadRequestException('Invalid Bearer token!', 401);
      }

      global $is_https;

      if ($is_https) {
        $uid = db_query("SELECT s.uid FROM {sessions} s WHERE s.ssid = :ssid", [':ssid' => $token])
          ->fetchField();
      } else {
        $uid = db_query("SELECT s.uid FROM {sessions} s WHERE s.sid = :sid", [':sid' => $token])
          ->fetchField();
      }

      if (!is_numeric($uid)) {
        throw new BadRequestException('No user found with this token!', 401);
      }

      $account = user_load($uid);
      if (!is_object($account)) {
        throw new BadRequestException('The user can not be loaded!', 401);
      }

      break;

    default:
      throw new BadRequestException('No authorization implemented for this request method.', 401);
  }

  $out = [
    'user' => [
      'id' => intval($account->uid),
      'name' => $account->name,
    ],
  ];

  drupal_json_output($out);

  drupal_page_footer();
}

The authorization parameter is the Drupal Session ID of the client. This Session ID is searched in the Drupal active sessions and the client is authorized if a match is found.

This was the final post in our blog series describing this functionality, and I have now described all the major parts' purpose, their implementation and internal connections. Make sure to check out the previous posts in our blog. Happy coding!

Jaa