You are here

RESTServer.inc in Services 7.3

Same filename and directory in other branches
  1. 6.3 servers/rest_server/includes/RESTServer.inc

Class for handling REST calls.

File

servers/rest_server/includes/RESTServer.inc
View source
<?php

/**
 * @file
 * Class for handling REST calls.
 */
class RESTServer {

  /* @var $negotiator ServicesContentTypeNegotiatorInterface */
  protected $negotiator;

  /* @var $context ServicesContextInterface */
  protected $context;
  protected $resources;
  protected $parsers;
  protected $formatters;
  protected $controller;

  /**
   * Constructor. Initialize properties.
   */
  function __construct(ServicesContextInterface $context, ServicesContentTypeNegotiatorInterface $negotiator, $resources, $parsers, $formatters) {
    $this->context = $context;
    $this->negotiator = $negotiator;
    $this->resources = $resources;
    $this->parsers = $parsers;
    $this->formatters = $formatters;
  }

  /**
   * Handles the call to the REST server
   */
  public function handle() {
    $controller = $this
      ->getController();
    $formatter = $this
      ->getResponseFormatter();
    services_set_server_info('resource_uri_formatter', array(
      &$this,
      'uri_formatter',
    ));

    // Initializing $arguments because an exception can be thrown for required
    // arguments and before $arguments is initialized for the exception handler.
    $arguments = array();
    try {

      // Parse the request data
      $arguments = $this
        ->getControllerArguments($controller);
      $result = services_controller_execute($controller, $arguments);
    } catch (ServicesException $e) {
      $result = $this
        ->handleException($e, $controller, $arguments);
      $no_body = variable_get('services_no_body_responses', array(
        204,
        304,
      ));
      $generate_body = variable_get('services_generate_error_body', TRUE);
      if (in_array($e
        ->getCode(), $no_body) && !$generate_body) {
        return '';
      }
    }
    return $this
      ->render($formatter, $result);
  }

  /**
   * Controller is part of the resource like
   *
   * array(
   *  'file' => array('type' => 'inc', 'module' => 'services', 'name' => 'resources/node_resource'),
   *  'callback' => '_node_resource_create',
   *  'args' => array(
   *    array(
   *      'name' => 'node',
   *      'optional' => FALSE,
   *      'source' => 'data',
   *      'description' => 'The node data to create',
   *      'type' => 'array',
   *    ),
   *  ),
   *  'access callback' => '_node_resource_access',
   *  'access arguments' => array('create'),
   *  'access arguments append' => TRUE,
   * ),
   *
   * This method determines what is the controller responsible for processing of the request.
   *
   * @return array
   */
  protected function getController() {
    if (empty($this->controller)) {
      $resource_name = $this
        ->getResourceName();
      if (empty($resource_name) || !isset($this->resources[$resource_name])) {
        return services_error(t('Could not find resource @name.', array(
          '@name' => $resource_name,
        )), 404);
      }
      $resource = $this->resources[$resource_name];
      $this->controller = $this
        ->resolveControllerApplyVersion($resource, $resource_name);
      if (empty($this->controller)) {
        return services_error(t('Could not find the controller.'), 404);
      }
    }
    return $this->controller;
  }

  /**
   * Wrapper around resolveController() to apply version.
   *
   * @param array $resource
   *   Resource definition
   * @param string $resource_name
   *   Name of the resource. Needed for applying version.
   *
   * @return array $controller
   *   Controller definition
   */
  protected function resolveControllerApplyVersion($resource, $resource_name) {
    $apply_version_method = '';
    $controller = $this
      ->resolveController($resource, $apply_version_method);
    services_request_apply_version($controller, array(
      'method' => $apply_version_method,
      'resource' => $resource['key'],
    ));
    return $controller;
  }

  /**
   * Canonical path is the url of the request without path of endpoint.
   *
   * For example endpoint has path 'rest'. Canonical of request to url
   * 'rest/node/1.php' will be 'node/1.php'.
   *
   * @return string
   */
  public function getCanonicalPath() {

    // Use drupal_static so we can clear this static cache during unit testing.
    // @see MockServicesRESTServerFactory constructor.
    $canonical_path =& drupal_static('RESTServerGetCanonicalPath');
    if (empty($canonical_path)) {
      $canonical_path = $this->context
        ->getCanonicalPath();
      $canonical_path = $this->negotiator
        ->getParsedCanonicalPath($canonical_path);
    }
    return $canonical_path;
  }

  /**
   * Explode canonical path to parts by '/'.
   *
   * @return array
   */
  protected function getCanonicalPathArray() {
    $canonical_path = $this
      ->getCanonicalPath();
    $canonical_path_array = explode('/', $canonical_path);
    return $canonical_path_array;
  }

  /**
   * Example. We have endpoint with path 'rest'.
   * Request is done to url /rest/node/1.php'.
   * Name of resource in this case is 'node'.
   *
   * @return string
   */
  protected function getResourceName() {
    $canonical_path_array = $this
      ->getCanonicalPathArray();
    $resource_name = array_shift($canonical_path_array);
    return $resource_name;
  }

  /**
   * Response formatter is responsible for encoding the response.
   *
   * @return array
   * example:
   * array(
   *  'xml' => array(
   *    'mime types' => array('application/xml', 'text/xml'),
   *    'formatter class' => 'ServicesXMLFormatter',
   *  ),
   * )
   */
  protected function getResponseFormatter() {
    $mime_type = '';
    $canonical_path_not_parsed = $this->context
      ->getCanonicalPath();
    $response_format = $this
      ->getResponseFormatFromURL($canonical_path_not_parsed);
    if (empty($response_format)) {
      $response_format = $this
        ->getResponseFormatContentTypeNegotiations($mime_type, $canonical_path_not_parsed, $this->formatters);
    }
    $formatter = array();
    if (isset($this->formatters[$response_format])) {
      $formatter = $this->formatters[$response_format];
    }

    // Check if we support the response format and determine the mime type
    if (empty($mime_type) && !empty($formatter)) {
      $mime_type = $formatter['mime types'][0];
    }
    if (empty($response_format) || empty($mime_type)) {
      return services_error(t('Unknown or unsupported response format.'), 406);
    }

    // Set the content type and render output.
    drupal_add_http_header('Content-type', $mime_type);
    return $formatter;
  }

  /**
   * Retrieve formatter from URL. If format is in the path, we remove it from $canonical_path.
   *
   * For example <endpoint>/<path>.<format>
   *
   * @param $canonical_path
   *
   * @return string
   */
  protected function getResponseFormatFromURL($canonical_path) {
    return $this->negotiator
      ->getResponseFormatFromURL($canonical_path);
  }

  /**
   * Determine response format and mime type using headers to negotiate content types.
   *
   * @param string $mime_type
   *   Mime type. This variable to be overriden.
   * @param string $canonical_path
   *   Canonical path of the request.
   * @param array $formats
   *   Enabled formats by endpoint.
   *
   * @return string
   *   Negotiated response format. For example 'json'.
   */
  protected function getResponseFormatContentTypeNegotiations(&$mime_type, $canonical_path, $formats) {
    return $this->negotiator
      ->getResponseFormatContentTypeNegotiations($mime_type, $canonical_path, $formats, $this->context);
  }

  /**
   * Determine the request method
   */
  protected function getRequestMethod() {
    return $this->context
      ->getRequestMethod();
  }

  /**
   * Formats a resource uri
   *
   * @param array $path
   *  An array of strings containing the component parts of the path to the resource.
   * @return string
   *  Returns the formatted resource uri
   */
  public function uri_formatter($path) {
    return url($this->context
      ->getEndpointPath() . '/' . join('/', $path), array(
      'absolute' => TRUE,
    ));
  }

  /**
   * Parses controller arguments from request
   *
   * @param array $controller
   *  The controller definition
   * @return void
   */
  protected function getControllerArguments($controller) {
    $path_array = $this
      ->getCanonicalPathArray();
    array_shift($path_array);
    $data = $this
      ->parseRequestBody();
    drupal_alter('rest_server_request_parsed', $data, $controller);
    $headers = $this
      ->parseRequestHeaders();
    drupal_alter('rest_server_headers_parsed', $headers);
    $sources = array(
      'path' => $path_array,
      'param' => $this->context
        ->getGetVariable(),
      'data' => $data,
      'headers' => $headers,
    );

    // Map source data to arguments.
    return $this
      ->getControllerArgumentsFromSources($controller, $sources);
  }

  /**
   * array $controller
   *   Controller definition
   * array $sources
   *   Array of sources for arguments. Consists of following elements:
   *  'path' - path requested
   *  'params' - GET variables
   *  'data' - parsed POST data
   *  'headers' - request headers
   *
   * @return array
   */
  protected function getControllerArgumentsFromSources($controller, $sources) {
    $arguments = array();
    if (!isset($controller['args'])) {
      return array();
    }
    foreach ($controller['args'] as $argument_number => $argument_info) {

      // Fill in argument from source
      if (isset($argument_info['source'])) {
        $argument_source = $argument_info['source'];
        if (is_array($argument_source)) {
          $argument_source_keys = array_keys($argument_source);
          $source_name = reset($argument_source_keys);
          $argument_name = $argument_source[$source_name];

          // Path arguments can be only integers. i.e.'path' => 0 and not 'path' => '0'.
          if ($source_name == 'path') {
            $argument_name = (int) $argument_name;
          }
          if (isset($sources[$source_name][$argument_name])) {
            $arguments[$argument_number] = $sources[$source_name][$argument_name];
          }
        }
        else {
          if (isset($sources[$argument_source])) {
            $arguments[$argument_number] = $sources[$argument_source];
          }
        }

        // Convert to specific data type.
        if (isset($argument_info['type']) && isset($arguments[$argument_number])) {
          switch ($argument_info['type']) {
            case 'array':
              $arguments[$argument_number] = (array) $arguments[$argument_number];
              break;
          }
        }
      }

      // When argument isn't set, insert default value if provided or
      // throw a exception if the argument isn't optional.
      if (!isset($arguments[$argument_number])) {
        if (!isset($argument_info['optional']) || !$argument_info['optional']) {

          // Send 401 on error for backward compatibility.
          // To override this behavior with the more appropriate http status
          // code 400 Bad Request, add this to settings.php:
          // $conf['services_deprecated_missing_arg_code'] = 400;
          //
          // @see See https://www.drupal.org/node/2880909
          return services_error(t('Missing required argument @arg', array(
            '@arg' => $argument_info['name'],
          )), variable_get('services_deprecated_missing_arg_code', 401));
        }

        // Set default value or NULL if default value is not set.
        $arguments[$argument_number] = isset($argument_info['default value']) ? $argument_info['default value'] : NULL;
      }
    }
    return $arguments;
  }
  protected function parseRequestHeaders() {
    $headers = array();
    $http_if_modified_since = $this->context
      ->getServerVariable('HTTP_IF_MODIFIED_SINCE');
    if (!empty($http_if_modified_since)) {
      $headers['IF_MODIFIED_SINCE'] = strtotime(preg_replace('/;.*$/', '', $http_if_modified_since));
    }
    return $headers;
  }

  /**
   * Parse request body based on $_SERVER['CONTENT_TYPE'].s
   *
   * @return array|mixed
   */
  protected function parseRequestBody() {
    $method = $this
      ->getRequestMethod();
    switch ($method) {
      case 'POST':
      case 'PUT':
        $server_content_type = $this->context
          ->getServerVariable('CONTENT_TYPE');
        if (!empty($server_content_type)) {
          $type = $this
            ->parseContentHeader($server_content_type);
        }

        // Get the mime type for the request, default to form-urlencoded
        if (isset($type['value'])) {
          $mime = $type['value'];
        }
        else {
          $mime = 'application/x-www-form-urlencoded';
        }

        // Get the parser for the mime type
        $parser = $this
          ->matchParser($mime, $this->parsers);
        if (!$parser) {
          return services_error(t('Unsupported request content type @mime', array(
            '@mime' => $mime,
          )), 406);
        }
        $data = array();
        if (class_exists($parser) && in_array('ServicesParserInterface', class_implements($parser))) {
          $parser_object = new $parser();
          $data = $parser_object
            ->parse($this->context);
        }
        return $data;
      default:
        return array();
    }
  }

  /**
   * Extract value of the header string.
   *
   * @param string $value
   *
   * @return array $type
   *   Value that is used $type['value']
   */
  protected function parseContentHeader($value) {
    $ret_val = array();
    $value_pattern = '/^([^;]+)(;\\s*(.+)\\s*)?$/';
    $param_pattern = '/([a-z]+)=(([^\\"][^;]+)|(\\"(\\\\"|[^"])+\\"))/';
    $vm = array();
    if (preg_match($value_pattern, $value, $vm)) {
      $ret_val['value'] = $vm[1];
      if (count($vm) > 2) {
        $pm = array();
        if (preg_match_all($param_pattern, $vm[3], $pm)) {
          $pcount = count($pm[0]);
          for ($i = 0; $i < $pcount; $i++) {
            $value = $pm[2][$i];
            if (drupal_substr($value, 0, 1) == '"') {
              $value = stripcslashes(drupal_substr($value, 1, mb_strlen($value) - 2));
            }
            $ret_val['param'][$pm[1][$i]] = $value;
          }
        }
      }
    }
    return $ret_val;
  }

  /**
   * Render results using formatter.
   *
   * @param array $formatter
   *   Formatter definition
   * @param $result
   *   Value to be rendered
   *
   * @return string
   *   Rendered result
   */
  protected function render($formatter, $result) {
    if (!isset($formatter['formatter class']) || array_search('ServicesFormatterInterface', class_implements($formatter['formatter class'])) === FALSE) {
      return services_error('Formatter is invalid.', 500);
    }
    $formatter_object = new $formatter['formatter class']();
    return $formatter_object
      ->render($result);
  }

  /**
   * Matches a mime-type against a set of parsers.
   *
   * @param string $mime
   *  The mime-type of the request.
   * @param array $parsers
   *  An associative array of parser callbacks keyed by mime-type.
   * @return mixed
   *  Returns a parser callback or FALSE if no match was found.
   */
  protected function matchParser($mime, $parsers) {
    $mimeparse = $this->negotiator
      ->mimeParse();
    $mime_type = $mimeparse
      ->best_match(array_keys($parsers), $mime);
    return $mime_type ? $parsers[$mime_type] : FALSE;
  }

  /**
   * Determine controller.
   *
   * @param array $resource
   *   Full definition of the resource.
   * @param string $operation
   *   Type of operation ('index', 'retrieve' etc.). We are going to override this variable.
   *   Needed for applying version.
   *
   * @return array
   *   Controller definition.
   */
  protected function resolveController($resource, &$operation) {
    $request_method = $this
      ->getRequestMethod();
    $canonical_path_array = $this
      ->getCanonicalPathArray();
    array_shift($canonical_path_array);
    $canon_path_count = count($canonical_path_array);
    $operation_type = NULL;
    $operation = NULL;

    // For any HEAD request return response "200 OK".
    if ($request_method == 'HEAD') {
      return services_error('OK', 200);
    }

    // For any OPTIONS request return only the headers.
    if ($request_method == 'OPTIONS') {
      exit;
    }

    // We do not group "if" conditions on purpose for better readability.
    // 'index' method.
    if ($request_method == 'GET' && isset($resource['operations']['index']) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['operations']['index'])) {
      $operation_type = 'operations';
      $operation = 'index';
    }

    // 'retrieve' method.
    // First path element should be not empty.
    if ($request_method == 'GET' && $canon_path_count >= 1 && isset($resource['operations']['retrieve']) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['operations']['retrieve']) && !empty($canonical_path_array[0])) {
      $operation_type = 'operations';
      $operation = 'retrieve';
    }

    // 'relationships'
    // First path element should be not empty,
    // second should be name of targeted action.
    if ($request_method == 'GET' && $canon_path_count >= 2 && isset($resource['relationships'][$canonical_path_array[1]]) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['relationships'][$canonical_path_array[1]], 1) && isset($canonical_path_array[0])) {
      $operation_type = 'relationships';
      $operation = $canonical_path_array[1];
    }

    // 'update'
    // First path element should be not empty.
    if ($request_method == 'PUT' && $canon_path_count >= 1 && isset($resource['operations']['update']) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['operations']['update']) && !empty($canonical_path_array[0])) {
      $operation_type = 'operations';
      $operation = 'update';
    }

    // 'delete'
    // First path element should be not empty.
    if ($request_method == 'DELETE' && $canon_path_count >= 1 && isset($resource['operations']['delete']) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['operations']['delete']) && !empty($canonical_path_array[0])) {
      $operation_type = 'operations';
      $operation = 'delete';
    }

    // 'create' method.
    // First path element should be not empty.
    if ($request_method == 'POST' && isset($resource['operations']['create']) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['operations']['create'])) {
      $operation_type = 'operations';
      $operation = 'create';
    }

    // 'actions'
    // First path element should be action name
    if ($request_method == 'POST' && $canon_path_count >= 1 && isset($resource['actions'][$canonical_path_array[0]]) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['actions'][$canonical_path_array[0]], 1)) {
      $operation_type = 'actions';
      $operation = $canonical_path_array[0];
    }

    // 'targeted_actions'
    // First path element should be not empty,
    // second should be name of targeted action.
    if ($request_method == 'POST' && $canon_path_count >= 2 && isset($resource['targeted_actions'][$canonical_path_array[1]]) && $this
      ->checkNumberOfArguments($canon_path_count, $resource['targeted_actions'][$canonical_path_array[1]], 1) && !empty($canonical_path_array[0])) {
      $operation_type = 'targeted_actions';
      $operation = $canonical_path_array[1];
    }
    if (empty($operation_type) || empty($operation) || empty($resource[$operation_type][$operation])) {
      return FALSE;
    }
    $controller = $resource[$operation_type][$operation];
    if (isset($resource['endpoint']['operations'][$operation]['settings'])) {

      // Add the endpoint's settings for the specified operation.
      $controller['endpoint'] = $resource['endpoint']['operations'][$operation]['settings'];
    }
    if (isset($resource['file']) && empty($controller['file'])) {
      $controller['file'] = $resource['file'];
    }
    return $controller;
  }

  /**
   * Count possible numbers of 'path' arguments of the method.
   */
  protected function checkNumberOfArguments($args_number, $resource_operation, $required_args = 0) {
    $not_required_args = 0;
    if (isset($resource_operation['args'])) {
      foreach ($resource_operation['args'] as $argument) {
        if (isset($argument['source']) && is_array($argument['source']) && isset($argument['source']['path'])) {
          if (!empty($argument['optional'])) {
            $not_required_args++;
          }
          else {
            $required_args++;
          }
        }
      }
    }
    return $args_number >= $required_args && $args_number <= $required_args + $not_required_args;
  }

  /**
   * Set proper header and message in case of exception.
   *
   * @param object $exception
   *   Exception object
   * @param array $controller
   *   Controller that was executed.
   * @param array $arguments
   *   Set of arguments.
   *
   * @return string $error_data
   *   Error message from exception.
   */
  public function handleException($exception, $controller = array(), $arguments = array()) {
    $error_code = $exception
      ->getCode();
    $error_message = $exception
      ->getMessage();
    $error_data = method_exists($exception, 'getData') ? $exception
      ->getData() : '';
    switch ($error_code) {
      case 204:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('204 No Content', $error_message);
        break;
      case 304:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('304 Not Modified', $error_message);
        break;
      case 401:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('401 Unauthorized', $error_message);
        break;
      case 404:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('404 Not found', $error_message);
        break;
      case 406:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('406 Not Acceptable', $error_message);
        break;
      case 200:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('200', $error_message);
        break;
      case 201:
        $error_header_status_message = $this
          ->formatHttpHeaderStatusMessage('201', $error_message);
        break;
      default:
        if ($error_code >= 400 && $error_code < 600) {
          $error_header_status_message = $this
            ->formatHttpHeaderStatusMessage($error_code, $error_message);
        }
        else {
          watchdog_exception('Services', $exception);
          $error_header_status_message = $this
            ->formatHttpHeaderStatusMessage('500 Internal Server Error', "An error occurred ({$error_code}): {$error_message}");
        }
        break;
    }
    $error_alter_array = array(
      'code' => $error_code,
      'header_message' => &$error_header_status_message,
      'body_data' => &$error_data,
    );
    drupal_alter('rest_server_execute_errors', $error_alter_array, $controller, $arguments);
    drupal_add_http_header('Status', strip_tags(str_replace(array(
      "\r",
      "\n",
    ), '', $error_header_status_message)));
    return $error_data;
  }

  /**
   * Formats a status code and message for use as an HTTP header message.
   *
   * @param $status_code
   *   An HTTP status code, e.g. "404 Not found" or "200".
   * @param $message
   *   A message string.
   *
   * @return string
   *   A properly formatted HTTP header status message.
   */
  protected function formatHttpHeaderStatusMessage($status_code, $message) {
    return "{$status_code} : {$message}";
  }

}

Classes

Namesort descending Description
RESTServer @file Class for handling REST calls.