You are here

restws.module in RESTful Web Services 7.2

Same filename and directory in other branches
  1. 7 restws.module

RESTful web services module.

File

restws.module
View source
<?php

/**
 * @file
 * RESTful web services module.
 */

/**
 * Returns info about all defined resources.
 *
 * @param string $resource
 *   By default null, else the info for the given resource will be returned.
 */
function restws_get_resource_info($resource = NULL) {
  $info =& drupal_static(__FUNCTION__);
  if (!isset($info)) {
    $info = module_invoke_all('restws_resource_info');
    drupal_alter('restws_resource_info', $info);
  }
  if (!empty($resource)) {
    return $info[$resource];
  }
  return $info;
}

/**
 * Returns info about all defined formats.
 */
function restws_get_format_info() {
  $info =& drupal_static(__FUNCTION__);
  if (!isset($info)) {
    $info = module_invoke_all('restws_format_info');
    drupal_alter('restws_format_info', $info);
  }
  return $info;
}

/**
 * Implements hook_restws_resource_info().
 *
 * Provides resources for all entity types.
 */
function restws_restws_resource_info() {
  foreach (entity_get_info() as $entity_type => $info) {
    $result[$entity_type] = array(
      'label' => $info['label'],
      'class' => 'RestWSEntityResourceController',
    );
  }
  return $result;
}

/**
 * Returns a instance of a resource controller.
 *
 * @return RestWSResourceControllerInterface
 *   A resource controller object.
 */
function restws_resource_controller($name) {
  $static =& drupal_static(__FUNCTION__);
  if (!isset($static[$name])) {
    $info = restws_get_resource_info();
    $static[$name] = isset($info[$name]) ? new $info[$name]['class']($name, $info[$name]) : FALSE;
  }
  return $static[$name];
}

/**
 * Implements hook_restws_format_info().
 *
 * Provides basic formats.
 */
function restws_restws_format_info() {
  $result = array(
    'json' => array(
      'label' => t('JSON'),
      'class' => 'RestWSFormatJSON',
      'mime type' => 'application/json',
    ),
    'xml' => array(
      'label' => t('XML'),
      'class' => 'RestWSFormatXML',
      'mime type' => 'application/xml',
    ),
  );
  if (module_exists('rdf')) {
    $result['rdf'] = array(
      'label' => t('RDF'),
      'class' => 'RestWSFormatRDF',
      'mime type' => 'application/rdf+xml',
    );
  }
  return $result;
}

/**
 * Returns an instance of a format.
 *
 * @return RestWSFormatInterface
 *   A resource format object.
 */
function restws_format($name) {
  $static =& drupal_static(__FUNCTION__);
  if (!isset($static[$name])) {
    $info = restws_get_format_info();
    $static[$name] = isset($info[$name]) ? new $info[$name]['class']($name, $info[$name]) : FALSE;
  }
  return $static[$name];
}

/**
 * Handles a request.
 *
 * @param string $op
 *   One of 'create', 'update', 'delete' or 'view'.
 */
function restws_handle_request($op, $format, $resource_name, $id = NULL, $payload = NULL) {
  $message = $status_message = '';
  if ($resource = restws_resource_controller($resource_name)) {

    // Allow other modules to change the web service request or react upon it.
    $request = array(
      'op' => &$op,
      'format' => &$format,
      'resource' => &$resource,
      'id' => &$id,
      'payload' => &$payload,
    );
    drupal_alter('restws_request', $request);

    // Since there is no access callback for query we need to use view.
    $access_op = $op == 'query' ? 'view' : $op;
    if (user_access('access resource ' . $resource_name) && $resource
      ->access($access_op, $id)) {
      try {
        $method = $op . 'Resource';
        if ($op == 'create') {
          print $format
            ->{$method}($resource, $payload);
          $status_message = '201 Created';
        }
        elseif ($op == 'query') {
          if (!$resource instanceof RestWSQueryResourceControllerInterface) {
            throw new RestWSException('Querying not available for this resource', 501);
          }
          print $format
            ->{$method}($resource, $payload);
        }
        else {
          print $format
            ->{$method}($resource, $id, $payload);
        }
        drupal_add_http_header('Content-Type', $format
          ->mimeType());
      } catch (RestWSException $e) {
        $message = check_plain($e
          ->getHTTPError()) . ': ' . check_plain($e
          ->getMessage());
        $status_message = $e
          ->getHTTPError();
      }
    }
    else {
      $status_message = $message = '403 Forbidden';
      watchdog('access denied', check_plain($_GET['q']), NULL, WATCHDOG_WARNING);
    }
  }
  else {
    $status_message = $message = '404 Not Found';
  }
  restws_terminate_request($status_message, $message);
}

/**
 * An exception defining the HTTP error code and message.
 */
class RestWSException extends Exception {
  public function getHTTPError() {
    $code = $this
      ->getCode();
    switch ($code) {
      case 403:
        return '403 Forbidden';
      case 404:
        return '404 Not Found';
      case 406:
        return '406 Not Acceptable';
      case 412:
        return '412 Precondition Failed';
      case 422:
        return '422 Unprocessable Entity';
      default:
        return '500 Internal Server Error';
    }
  }

}

/**
 * Implements hook_init().
 *
 * Sets the router item for the current path when it has a format.
 */
function restws_init() {

  // Determine the router item for the current path.
  _restws_determine_router_item();
}

/**
 * Reroute requests that come from *.{format} paths.
 * For example /node/2.json will need a correct page callback to be treated as
 * a restws request.
 *
 * Also, the restws_basic_auth module will need to use this function as well
 * to perform this again after logging a user in.
 */
function _restws_determine_router_item() {

  // Determine the position of the resource and resource id in the path.
  if (strpos(request_path(), '.') === FALSE) {
    return;
  }
  $menu_paths = array();
  foreach (restws_get_resource_info() as $resource => $info) {
    $menu_paths[] = isset($info['menu_path']) ? $info['menu_path'] : $resource;
  }
  $formats = array_keys(restws_get_format_info());

  // The pattern matches menu paths like 'node', 'user' followed by an ID.
  // This ID cannot start with a 0 but can contain any digit.
  $pattern = '#^((?:';
  $pattern .= implode('|', $menu_paths);
  $pattern .= ')\\/[1-9][0-9]*)\\.(?:';

  // The path will end with a format that is supported by restws, for example
  // 'json' or 'xml'.
  $pattern .= implode('|', $formats);
  $pattern .= ')$#i';

  // Replace pattern precisely once.
  $count = 0;
  $path = preg_replace($pattern, '\\1', request_path(), 1, $count);

  // When the pattern matches and there is no menu router for the request
  // path, substitute this module's page callback.
  if ($count && !menu_get_item()) {
    $router_item = menu_get_item($path);
    menu_set_item(NULL, $router_item);
  }
}

/**
 * Implements hook_menu_alter().
 */
function restws_menu_alter(&$items) {
  foreach (restws_get_resource_info() as $resource => $info) {

    // Resource full path (e.g. /node/% or /user/%) for accessing specific
    // resources.
    $menu_path = isset($info['menu_path']) ? $info['menu_path'] . '/%' : $resource . '/%';

    // Replace existing page callbacks with our own (e.g. node/%)
    if (isset($items[$menu_path])) {

      // Prepend the page callback and the resource to the page arguments.
      // So we can re-use it on standard HTML page requests.
      array_unshift($items[$menu_path]['page arguments'], $resource, $items[$menu_path]['page callback']);
      $items[$menu_path]['page callback'] = 'restws_page_callback';
    }
    elseif (isset($items[$menu_path . $resource])) {
      $menu_path = $menu_path . $resource;
      array_unshift($items[$menu_path]['page arguments'], $resource, $items[$menu_path]['page callback']);
      $items[$menu_path]['page callback'] = 'restws_page_callback';
    }
    else {
      $items[$menu_path] = array(
        'page callback' => 'restws_page_callback',
        'page arguments' => array(
          $resource,
          'drupal_not_found',
        ),
        'access callback' => TRUE,
        'type' => MENU_CALLBACK,
      );
    }

    // Resource base path (e.g. /node or /user) for creating resources.
    $menu_path = isset($info['menu_path']) ? substr($menu_path, 0, strlen($menu_path) - 2) : $resource;
    if (isset($items[$menu_path])) {

      // Prepend the page callback and the resource to the page arguments.
      if (!isset($items[$menu_path]['page arguments'])) {
        $items[$menu_path]['page arguments'] = array();
      }
      array_unshift($items[$menu_path]['page arguments'], $resource, $items[$menu_path]['page callback']);
      $items[$menu_path]['page callback'] = 'restws_page_callback';
    }
    else {
      $items[$menu_path] = array(
        'page callback' => 'restws_page_callback',
        'page arguments' => array(
          $resource,
          'drupal_not_found',
        ),
        'access callback' => TRUE,
        'type' => MENU_CALLBACK,
      );
    }

    // Querying menu paths.
    foreach (array_keys(restws_get_format_info()) as $format) {

      // Resource base path URLs with the suffixes (e.g. node.json or user.xml)
      // for querying.
      if (isset($items["{$menu_path}.{$format}"])) {

        // Prepend the page callback and the resource to the page arguments.
        if (!isset($items["{$menu_path}.{$format}"]['page arguments'])) {
          $items["{$menu_path}.{$format}"]['page arguments'] = array();
        }
        array_unshift($items["{$menu_path}.{$format}"]['page arguments'], $resource, $items["{$menu_path}.{$format}"]['page callback']);
        $items["{$menu_path}.{$format}"]['page callback'] = 'restws_page_callback';
      }
      else {
        $items["{$menu_path}.{$format}"] = array(
          'page callback' => 'restws_page_callback',
          'page arguments' => array(
            $resource,
            'drupal_not_found',
          ),
          'access callback' => TRUE,
          'type' => MENU_CALLBACK,
        );
      }
    }
  }
}

/**
 * Menu page callback.
 *
 * @param string $resource
 *   The name of the resource.
 * @param string $page_callback
 *   The page callback to pass through when the request is not handled by this
 *   module. If no other pre-existing callback is used, 'drupal_not_found'
 *   should be passed explicitly.
 * @param mixed $arg1,...
 *   Further arguments that are passed through to the given page callback.
 */
function restws_page_callback($resource, $page_callback) {

  // Determine the position of the resource and resource id in the path.
  $resource_info = restws_get_resource_info($resource);
  $resource_pos = isset($resource_info['menu_path']) ? count(explode('/', $resource_info['menu_path'])) - 1 : 0;
  $id_arg = arg($resource_pos + 1);
  $resource_arg = arg($resource_pos);
  $format = FALSE;
  $id = NULL;

  // Check for an appended .format string on GET requests only to avoid CSRF
  // attacks on POST requests.
  if ($_SERVER['REQUEST_METHOD'] == 'GET' && ($pos = strrpos($id_arg, '.')) && ($format_name = substr($id_arg, $pos + 1))) {
    $id = substr($id_arg, 0, $pos);
    $format = restws_format($format_name);
  }
  elseif ($_SERVER['REQUEST_METHOD'] == 'GET' && ($pos = strrpos($resource_arg, '.')) && ($format_name = substr($resource_arg, $pos + 1))) {
    $format = restws_format($format_name);
  }
  else {
    $id = $id_arg;
    switch ($_SERVER['REQUEST_METHOD']) {
      case 'POST':
      case 'PUT':

        // Get format MIME type form HTTP Content type header.
        $parts = explode(';', $_SERVER['CONTENT_TYPE'], 2);
        $format = restws_format_mimetype($parts[0]);
        break;
      case 'DELETE':
        if (isset($_SERVER['HTTP_ACCEPT'])) {
          $parts = explode(',', $_SERVER['HTTP_ACCEPT'], 2);
          $format = restws_format_mimetype($parts[0]);
        }
        if (!$format) {

          // We don't care about the format, just pick JSON.
          $format = restws_format('json');
        }
        break;
      default:

        // Get the format MIME type form the HTTP Accept header.
        // Ignore requests from web browsers that accept HTML.
        if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'html') === FALSE) {

          // Use the first MIME type.
          $parts = explode(',', $_SERVER['HTTP_ACCEPT'], 2);
          $format = restws_format_mimetype($parts[0]);
        }

        // Consumers should not use this URL if page caching is enabled.
        // Drupal's page cache IDs are only determined by URL path, so this
        // could poison the HTML page cache. A browser request to /node/1 could
        // suddenly return JSON if the cache was primed with this RESTWS
        // response.
        if ($format && !isset($_COOKIE[session_name()]) && variable_get('cache')) {

          // Redirect to the URL path containing the format name instead.
          drupal_goto($_GET['q'] . '.' . $format
            ->getName(), array(
            'query' => drupal_get_query_parameters(),
          ), 301);
        }
    }
  }
  if ($format) {
    switch ($_SERVER['REQUEST_METHOD']) {
      case 'POST':
        $op = 'create';
        break;
      case 'PUT':
        $op = 'update';
        break;
      case 'DELETE':
        $op = 'delete';
        break;
      default:
        if (!empty($id)) {
          $op = 'view';
        }
        else {
          $op = 'query';
        }
    }

    // CSRF protection on write operations.
    if (!in_array($_SERVER['REQUEST_METHOD'], array(
      'GET',
      'HEAD',
      'OPTIONS',
      'TRACE',
    )) && !restws_csrf_validation()) {
      restws_terminate_request('403 Forbidden', '403 Access Denied: CSRF validation failed');
    }
    $payload = file_get_contents('php://input');
    if ($file = variable_get('restws_debug_log')) {
      $log = date(DATE_ISO8601) . "\n";
      $log .= 'Resource: ' . $resource . "\n";
      $log .= 'Operation: ' . $op . "\n";
      $log .= 'Format: ' . $format
        ->mimeType() . "\n";
      $log .= 'Id: ' . $id . "\n";
      $log .= 'Payload: ' . $payload . "\n";
      $log .= "----------------------------------------------------------------\n";
      file_put_contents($file, $log, FILE_APPEND);
    }
    restws_handle_request($op, $format, $resource, $id, $payload);
  }

  // @todo: Determine human readable URIs and redirect, if there is no
  // page callback.
  // Fall back to the passed $page_callback and pass through more arguments.
  $args = func_get_args();
  return call_user_func_array($page_callback, array_slice($args, 2));
}

/**
 * Returns the URI used for the given resource.
 *
 * @param string $resource
 *   The resource for which the URI should be returned.
 * @param int $id
 *   The resource ID or NULL if only the base path should be returned.
 * @param array $options
 *   Optional array that is passed to url().
 */
function restws_resource_uri($resource, $id = NULL, array $options = array()) {
  $info = restws_get_resource_info($resource);
  $basepath = isset($info['menu_path']) ? $info['menu_path'] : $resource;
  $sub_path = isset($id) ? "/{$id}" : '';

  // Avoid having the URLs aliased.
  $base_options = array(
    'absolute' => TRUE,
    'alias' => TRUE,
  );
  $options += $base_options;
  return url($basepath . $sub_path, $options);
}

/**
 * Returns the format instance for a given MIME type.
 *
 * @param string $mime
 *   The MIME type, e.g. 'application/json' or 'application/xml'.
 *
 * @return bool|RestWSFormatInterface
 *   The format controller or FALSE if the format was not found.
 */
function restws_format_mimetype($mime) {
  foreach (restws_get_format_info() as $format_name => $info) {
    if ($info['mime type'] == $mime) {
      return restws_format($format_name);
    }
  }
  return FALSE;
}

/**
 * Implements hook_permission().
 */
function restws_permission() {
  $permissions = array();

  // Create service access permissions per resource type.
  foreach (restws_get_resource_info() as $type => $info) {
    $permissions['access resource ' . $type] = array(
      'title' => t('Access the resource %resource', array(
        '%resource' => $type,
      )),
    );
  }
  return $permissions;
}

/**
 * Implements hook_module_implements_alter().
 */
function restws_module_implements_alter(&$implementations, $hook) {

  // Make sure that restws runs last.
  if ($hook == 'menu_alter') {
    $group = $implementations['restws'];
    unset($implementations['restws']);
    $implementations['restws'] = $group;
  }
}

/**
 * Return all available meta controls.
 */
function restws_meta_controls() {
  $controls = array(
    'sort' => 'sort',
    'direction' => 'direction',
    'page' => 'page',
    'limit' => 'limit',
    'full' => 'full',
  );
  drupal_alter('restws_meta_controls', $controls);
  return $controls;
}

/**
 * Ensures that a request with cookies has the required CSRF header set.
 *
 * @return bool
 *   TRUE if the request passed the CSRF protection, FALSE otherwise.
 */
function restws_csrf_validation() {

  // This check only applies if the user was successfully authenticated and the
  // request comes with a session cookie.
  if (user_is_logged_in() && !empty($_COOKIE[session_name()])) {
    return isset($_SERVER['HTTP_X_CSRF_TOKEN']) && drupal_valid_token($_SERVER['HTTP_X_CSRF_TOKEN'], 'restws');
  }
  return TRUE;
}

/**
 * Implements hook_menu().
 */
function restws_menu() {
  $items['restws/session/token'] = array(
    'page callback' => 'restws_session_token',
    // Only authenticated users are allowed to retrieve a session token.
    'access callback' => 'user_is_logged_in',
    'type' => MENU_CALLBACK,
  );
  return $items;
}

/**
 * Page callback: returns a session token for the currently active user.
 */
function restws_session_token() {
  drupal_add_http_header('Content-Type', 'text/plain');
  print drupal_get_token('restws');
  drupal_exit();
}

/**
 * Terminates the current request with the given status code and message.
 *
 * @param $status_message
 *   (optional) The status message (including the status code) to return via the
 *   HTTP Status header.
 * @param $message
 *   (optional) A message displayed as body of the response.
 */
function restws_terminate_request($status_message = NULL, $message = NULL) {
  if (!empty($message)) {
    echo $message;
  }
  if (!empty($status_message)) {
    drupal_add_http_header('Status', $status_message);
  }
  drupal_page_footer();
  exit;
}

Functions

Namesort descending Description
restws_csrf_validation Ensures that a request with cookies has the required CSRF header set.
restws_format Returns an instance of a format.
restws_format_mimetype Returns the format instance for a given MIME type.
restws_get_format_info Returns info about all defined formats.
restws_get_resource_info Returns info about all defined resources.
restws_handle_request Handles a request.
restws_init Implements hook_init().
restws_menu Implements hook_menu().
restws_menu_alter Implements hook_menu_alter().
restws_meta_controls Return all available meta controls.
restws_module_implements_alter Implements hook_module_implements_alter().
restws_page_callback Menu page callback.
restws_permission Implements hook_permission().
restws_resource_controller Returns a instance of a resource controller.
restws_resource_uri Returns the URI used for the given resource.
restws_restws_format_info Implements hook_restws_format_info().
restws_restws_resource_info Implements hook_restws_resource_info().
restws_session_token Page callback: returns a session token for the currently active user.
restws_terminate_request Terminates the current request with the given status code and message.
_restws_determine_router_item Reroute requests that come from *.{format} paths. For example /node/2.json will need a correct page callback to be treated as a restws request.

Classes

Namesort descending Description
RestWSException An exception defining the HTTP error code and message.