REST.inc

From BioAssist
Jump to: navigation, search

REST.php was programmed by User:Pieterb. It aids in developing RESTful applications with PHP.

  • Handles content negotiation.
  • Handles spoofing of HTTP methods
    • DELETE, GET and PUT through POST
    • DELETE through GET (which you shouldn't do because it breaks GET idempotency)
  • Aids in generating common HTTP headers.
  • Generates nice human- and machine readible error messages.

It can be downloaded here.

<?php

/*·************************************************************************
 * Copyright © 2008 by Pieter van Beek, Almere, the Netherlands           *
 * <pieterb@sara.nl> <pieter@djinnit.com>                                 *
 *                                                                        *
 * This library is free software: you can redistribute it and/or modify   *
 * it under the terms of the GNU General Public License as published by   *
 * the Free Software Foundation, either version 3 of the License, or      *
 * (at your option) any later version.                                    *
 *                                                                        *
 * This library is distributed in the hope that it will be useful,        *
 * but WITHOUT ANY WARRANTY; without even the implied warranty of         *
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the          *
 * GNU General Public License for more details.                           *
 *                                                                        *
 * You should have received a copy of the GNU General Public License      *
 * along with this library.  If not, see <http://www.gnu.org/licenses/>   *
 **************************************************************************/


/////////////////////
// METHOD SPOOFING //
/////////////////////
// Many REST application require "method spoofing", meaning that one HTTP
// method is used while the effect of another method is intended. There are
// a few reasons to do method spoofing:
// 1. Many web-servers won't allow arbitrary length URL's in GET requests.
//    Therefor, if you have a very long URL, for example because there are
//    many parameters, you may need to use POST instead of GET, and put the
//    parameters in a application/x-www-form-urlencoded body.
// 2. Many web-browsers support GET and POST, but no other methods. If you
//    want to do a DELETE or PUT request with a browser, you'll have to use
//    GET or POST instead.
// The code below allows you to spoof GET, PUT and DELETE requests as POST
// requests. Additionally, it allows you to spoof DELETE as GET, but the use
// of that feature is discouraged, because it breaks the rule that GET
// requests are idempotent.
// To spoof one method as another, simply specify the intended method as a
// GET parameter. E.g., to spoof a PUT request as a POST request, send a
// POST request to http://some.url/?method=PUT and put your data in the body
// of the request.

if ($_SERVER['REQUEST_METHOD'] == 'POST' and
    isset($_GET['method'])) {
  $_GET['method'] = strtoupper($_GET['method']);
  if (!in_array($_GET['method'], array('DELETE', 'GET', 'PUT')))
    REST::inst()->fatal(
      'METHOD_NOT_ALLOWED', <<<EOS
You tried to tunnel an HTTP {$_GET['method']} request through an HTTP POST request.
EOS
    );
  $_SERVER['REQUEST_METHOD'] = $_GET['method'];
  unset($_GET['method']);
  $_SERVER['QUERY_STRING'] = http_build_query($_GET);
  $_SERVER['REQUEST_URI'] = $_SERVER['SCRIPT_NAME'];
  if ($_SERVER['QUERY_STRING'] != '')
    $_SERVER['REQUEST_URI'] .= '?' . $_SERVER['QUERY_STRING'];
} elseif ($_SERVER['REQUEST_METHOD'] == 'GET' and
          isset($_GET['method'])) {
  $_GET['method'] = strtoupper($_GET['method']);
  if ($_GET['method'] != 'DELETE')
    REST::inst()->fatal(
      'METHOD_NOT_ALLOWED', <<<EOS
You tried to tunnel an HTTP {$_GET['method']} request through an HTTP GET request.
EOS
    );
  $_SERVER['REQUEST_METHOD'] = 'DELETE';
  unset($_GET['method']);
  $_SERVER['QUERY_STRING'] = http_build_query($_GET);
  $_SERVER['REQUEST_URI'] = $_SERVER['SCRIPT_NAME'];
  if ($_SERVER['QUERY_STRING'] != '')
    $_SERVER['REQUEST_URI'] .= '?' . $_SERVER['QUERY_STRING'];
}

/**
 * A singleton class to REST-enable your scripts.
 */
final class REST {


/** @var REST */
private static $INSTANCE = null;
/**
 * Singleton factory.
 * @return REST
 */
public static function inst() {
  if (is_null(self::$INSTANCE)) self::$INSTANCE = new REST();
  return self::$INSTANCE;
}
/**
 * Constructor.
 * Use the singleton factory REST::inst() instead of this constructor.
 */
private function __construct() {}

/**
 * Parsed version of $_SERVER['HTTP_ACCEPT'].
 * @var array
 * @see REST::http_accept()
 */
private $HTTP_ACCEPT = null;

/**
 * Parsed version of $_SERVER['HTTP_ACCEPT']
 * @return array array( 'mime_type' => array( 'qualifier' => <value>, ...), ... )
 */
public function http_accept() {
  if ($this->HTTP_ACCEPT === null) {
    $this->HTTP_ACCEPT = array();
    // Initialize $this->HTTP_ACCEPT
    if (isset( $_SERVER['HTTP_ACCEPT'] ) &&
        $_SERVER['HTTP_ACCEPT'] !== '') {
      $mime_types = explode(',', $_SERVER['HTTP_ACCEPT']);
      foreach ($mime_types as $value) {
        $value = split(';', $value);
        $mt = array_shift($value);
        $this->HTTP_ACCEPT[$mt]['q'] = 1.0;
        foreach ($value as $v)
          if (preg_match('/^\s*([^\s=]+)\s*=\s*([^;]+?)\s*$/', $v, $matches))
            $this->HTTP_ACCEPT[$mt][$matches[1]] =
              is_numeric($matches[2]) ? (float)$matches[2] : $matches[2];
      }
    }
  }
  return $this->HTTP_ACCEPT;
}

/**
 * Computes the best Content-Type, based on client and server preferences.
 * In parameter <var>$mime_types</var>, the server can specify a list of
 * mime-types it supports. The client should specify its preferences
 * through the HTTP/1.1 'Accept' header.
 *
 * If no acceptable mime-type can be agreed upon, and <var>$fallback</var>
 * parameter isn't set, then an error is returned to the client and this
 * method doesn't return.
 * @param $mime_types array A list of mime-types, with their associated
 * quality, e.g.
 * <code>array('text/plain' => 0.8, 'text/html' => '1.0')</code>
 * @param $fallback string|null The mime-type to use if we can't agree on
 *                  a mime-type supported by both client and server.
 * @return string Please note that if <var>$fallback<var> isn't set, this
 *         method might not return at all.
 */
function best_content_type($mime_types, $fallback = null) {
  $retval = $fallback;
  $best = -1;
  foreach ($this->http_accept() as $key => $value) {
    $regexp = preg_quote( $key, '/' );
    $regexp = str_replace('\\*', '.*', $regexp);
    foreach ($mime_types as $mkey => $mvalue) {
      if (preg_match("/^{$regexp}\$/", $mkey)) {
        $q = (float)($value['q']) * (float)($mvalue);
        if ($q > $best) {
          $best = $q;
          $retval = $key;
        }
      }
    }
  }
  if (is_null($retval)) {
    $message = "<p>Sorry, we couldn't agree on a mime-type. I can serve any of the following</p>\n<ul>";
    foreach (array_keys($mime_types) as $mime_type)
      $message .= '<li>' . htmlspecialchars($mime_type) . "</li>\n";
    $message .= '</ul>';
    $this->fatal( 'NOT_ACCEPTABLE', $message );
  }
  return $retval;
}


##########################
# HTTP header generation #
##########################
/**
 * Outputs HTTP/1.1 headers.
 * @param $properties array|string An array of headers to print, e.g.
 * <code>array( 'Content-Language' => 'en-us' )</code> If there's a
 * key «status» in the array, it is used for the 'HTTP/1.X ...'
 * status header, e.g.<code>array(
 *   'status'       => 'CREATED',
 *   'Content-Type' => 'text/plain'
 * )</code> If <var>$properties</var> is a string, it is taken as the
 * Content-Type, e.g.<code>$rest->header('text/plain')</code> is exactly
 * equivalent to
 * <code>$rest->header(array('Content-Type' => 'text/plain'));</code>
 * @return REST $this
 * @see status_code()
 */
public function header($properties) {
  if (is_string($properties))
    $properties = array( 'Content-Type' => $properties );
  if (isset($properties['status'])) {
    header(
      $_SERVER['SERVER_PROTOCOL'] . ' ' .
      $this->status_code($properties['status'])
    );
    unset( $properties['status'] );
  }
  if (isset($properties['Location']))
    $properties['Location'] = $this->full_path($properties['Location']);
  foreach($properties as $key => $value)
    header("$key: $value");
}


private $base = null;
/**
 * Returns the base of the current URL, of the form "protocol://server:port"
 * @return string
 */
public function base() {
  if ( is_null( $this->base ) ) {
    $this->base = isset($_SERVER['HTTPS']) ? 'https://' : 'http://';
    $this->base .= $_SERVER['SERVER_NAME'];
    if ( ! isset($_SERVER['HTTPS']) && $_SERVER['SERVER_PORT'] != 80 or
           isset($_SERVER['HTTPS']) && $_SERVER['SERVER_PORT'] != 443 )
      $this->base .= ":{$_SERVER['SERVER_PORT']}";
  }
  return $this->base;
}


/**
 * Turns any path into an absolute path.
 * Since HTTP/1.1, paths in headers (like Location: and Refresh:) must be
 * full paths. This method turns any URL (relative or absolute) into a full
 * absolute URL. This function doesn't take the PATH_INFO into account,
 * which is different from what browsers do! (But it makes more sense on a
 * server.)
 * 
 * For example, if the currently executing URL is
 * <tt>http://some.domain/some/dir/index.php/some/path</tt>, then
 * full_path('../index.php') and full_path('/some/index.php') would both
 * return <tt>http://some.domain/some/index.php</tt>
 * @param string $path
 * @return string
 */
public function full_path( $path ) {
  if ( preg_match( '/^\\w+:/', $path ) ) # full path:
    return $path;
  if ( substr($path, 0, 1) == '/' ) # absolute path:
    return $this->base() . $path;
  # relative path:
  $dir = dirname($_SERVER['SCRIPT_NAME']);
  foreach (split( '/', $path ) as $value) {
    switch ($value) {
    case '..':
      $dir = dirname($dir);
      break;
    case '.':
      break;
    default:
      if ($dir != '/') $dir .= '/';
      $dir .= $value;
    }
  }
  return $this->base() . $dir;
}


/**
 * Returns some error to the client.
 * This method should only be used for simple errors, that don't require
 * additional HTTP headers to be sent along.
 * 
 * @param $status string The status code to send to the client.
 * @param $message string The message in the content body. If it doesn't
 *        start with an HTML tag, it'll be surrounded by <p>paragraph</p>
 *        tags, to comply with the HTML 1.0 Strict standard.
 * @return void This function never returns.
 * @see REST::status_code() for allowed statuses.
 */
public function fatal($status, $message, $stylesheet = null) {
  $this->header(array(
    'status'       => $status,
    'Content-Type' => 'text/html; charset=utf-8'
  ));
  if (substr($message, 1) <> '<') $message = "<p id=\"message\">$message</p>";
  if (!empty($stylesheet))
    $stylesheet = '<link rel="stylesheet" type="text/css" href="' .
      $this->full_path($stylesheet) . '" />';
  $status_code = $this->status_code($status);
  echo $this->xml_header() . <<<EOS
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
  <head>
    <title>$status_code</title>$stylesheet
    $stylesheet
  </head>
  <body>
    <h1 id="status_code">HTTP/1.1 $status_code</h1>
    $message
  </body>
</html>
EOS;
  exit;
}


/**
 * The default xml header.
 * @param string $encoding the used encoding (defaults to 'UTF-8')
 * @param string $version the used xml version (defaults to '1.0')
 * @return string The xml header with proper version and encoding
 */
public function xml_header($encoding = 'UTF-8', $version = '1.0') {
  return "<?xml version=\"$version\" encoding=\"$encoding\"?>\n";
}


/**
 * An xsl parser directive header.
 * @param $url string the url of the xsl stylsheet to use
 * @return string An xsl parser directive, pointing at <code>$url</code>
 */
public function xsl_header($url) {
  $url = htmlentities($this->full_path($url));
  return "<?xml-stylesheet type=\"text/xsl\" href=\"$url\"?>\n";
}

private $STATUS_CODES = array(
  'CONTINUE'                       => '100 Continue',
  'SWITCHING_PROTOCOLS'            => '101 Switching Protocols',
  'PROCESSING'                     => '102 Processing', # A WebDAV extension
  'OK'                             => '200 OK',
  'CREATED'                        => '201 Created',
  'ACCEPTED'                       => '202 Accepted',
  'NON-AUTHORITATIVE_INFORMATION'  => '203 Non-Authoritative Information', # HTTP/1.1 only
  'NO_CONTENT'                     => '204 No Content',
  'RESET_CONTENT'                  => '205 Reset Content',
  'PARTIAL_CONTENT'                => '206 Partial Content',
  'MULTI-STATUS'                   => '207 Multi-Status', # A WebDAV extension
  'MULTIPLE_CHOICES'               => '300 Multiple Choices',
  'MOVED PERMANENTLY'              => '301 Moved Permanently',
  'FOUND'                          => '302 Found',
  'SEE_OTHER'                      => '303 See Other', # HTTP/1.1 only
  'NOT_MODIFIED'                   => '304 Not Modified',
  'USE_PROXY'                      => '305 Use Proxy', # HTTP/1.1 only
  'SWITCH_PROXY'                   => '306 Switch Proxy',
  'TEMPORARY_REDIRECT'             => '307 Temporary Redirect', # HTTP/1.1 only
  'BAD_REQUEST'                    => '400 Bad Request',
  'UNAUTHORIZED'                   => '401 Unauthorized',
  'PAYMENT_REQUIRED'               => '402 Payment Required',
  'FORBIDDEN'                      => '403 Forbidden',
  'NOT_FOUND'                      => '404 Not Found',
  'METHOD_NOT_ALLOWED'             => '405 Method Not Allowed',
  'NOT_ACCEPTABLE'                 => '406 Not Acceptable',
  'PROXY_AUTHENTICATION_REQUIRED'  => '407 Proxy Authentication Required',
  'REQUEST_TIMEOUT'                => '408 Request Timeout',
  'CONFLICT'                       => '409 Conflict',
  'GONE'                           => '410 Gone',
  'LENGTH_REQUIRED'                => '411 Length Required',
  'PRECONDITION_FAILED'            => '412 Precondition Failed',
  'REQUEST_ENTITY_TOO_LARGE'       => '413 Request Entity Too Large',
  'REQUEST-URI_TOO_LONG'           => '414 Request-URI Too Long',
  'UNSUPPORTED_MEDIA_TYPE'         => '415 Unsupported Media Type',
  'REQUESTED_RANGE_NOT_SATISFIABLE'=> '416 Requested Range Not Satisfiable',
  'EXPECTATION_FAILED'             => '417 Expectation Failed',
  'UNPROCESSABLE_ENTITY'           => '422 Unprocessable Entity', # A WebDAV/RFC2518 extension
  'LOCKED'                         => '423 Locked', # A WebDAV/RFC2518 extension
  'FAILED_DEPENDENCY'              => '424 Failed Dependency', # A WebDAV/RFC2518 extension
  'UNORDERED_COLLECTION'           => '425 Unordered Collection',
  'UPGRADE_REQUIRED'               => '426 Upgrade Required', # an RFC2817 extension
  'RETRY_WITH'                     => '449 Retry With', # a Microsoft extension
  'INTERNAL_SERVER_ERROR'          => '500 Internal Server Error',
  'NOT_IMPLEMENTED'                => '501 Not Implemented',
  'BAD_GATEWAY'                    => '502 Bad Gateway',
  'SERVICE_UNAVAILABLE'            => '503 Service Unavailable',
  'GATEWAY_TIMEOUT'                => '504 Gateway Timeout',
  'HTTP_VERSION_NOT_SUPPORTED'     => '505 HTTP Version Not Supported',
  'VARIANT_ALSO_VARIES'            => '506 Variant Also Negotiates', # an RFC2295 extension
  'INSUFFICIENT_STORAGE'           => '507 Insufficient Storage (WebDAV)', # A WebDAV extension
  'BANDWIDTH_LIMIT_EXCEEDED'       => '509 Bandwidth Limit Exceeded',
  'NOT_EXTENDED'                   => '510 Not Extended', # an RFC2774 extension
);
/**
 * Returns a full HTTP/1.1 status code.
 * E.g. status_code('OK') returns '200 OK', and
 * status_code('INTERNAL_SERVER_ERROR') returns '500 Internal Server Error'.
 * This makes your code more readible.
 * 
 * Check the source code for all allowed status codes.
 * @param string $name
 * @return string
 */
public function status_code($name) {
  if (!isset($this->STATUS_CODES[$name]))
    throw new Exception("Unknown status $name");
  return $this->STATUS_CODES[$name];
}


} # class REST

?>