$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'; } // Also replace wildcard loaders (e.g. node/%node) 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(), 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; }