* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ /* Turn on namespace in PHP5.3 if you have namespace collisions from the Tonic classnames namespace Tonic; use \ReflectionClass as ReflectionClass; use \ReflectionMethod as ReflectionMethod; use \Exception as Exception; //*/ /** * Model the data of the incoming HTTP request * @namespace Tonic\Lib */ class Request { /** * The requested URI * @var str */ public $uri; /** * The URI where the front controller is positioned in the server URI-space * @var str */ public $baseUri = ''; /** * Array of possible URIs based upon accept and accept-language request headers in order of preference * @var str[] */ public $negotiatedUris = array(); /** * Array of possible URIs based upon accept request headers in order of preference * @var str[] */ public $formatNegotiatedUris = array(); /** * Array of possible URIs based upon accept-language request headers in order of preference * @var str[] */ public $languageNegotiatedUris = array(); /** * Array of accept headers in order of preference * @var str[][] */ public $accept = array(); /** * Array of accept-language headers in order of preference * @var str[][] */ public $acceptLang = array(); /** * Array of accept-encoding headers in order of preference * @var str[] */ public $acceptEncoding = array(); /** * Map of file/URI extensions to mimetypes * @var str[] */ public $mimetypes = array( 'html' => 'text/html', 'txt' => 'text/plain', 'php' => 'application/php', 'css' => 'text/css', 'js' => 'application/javascript', 'json' => 'application/json', 'xml' => 'application/xml', 'rss' => 'application/rss+xml', 'atom' => 'application/atom+xml', 'gz' => 'application/x-gzip', 'tar' => 'application/x-tar', 'zip' => 'application/zip', 'gif' => 'image/gif', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'ico' => 'image/x-icon', 'swf' => 'application/x-shockwave-flash', 'flv' => 'video/x-flv', 'avi' => 'video/mpeg', 'mpeg' => 'video/mpeg', 'mpg' => 'video/mpeg', 'mov' => 'video/quicktime', 'mp3' => 'audio/mpeg' ); /** * Supported HTTP methods * @var str[] */ public $HTTPMethods = array( 'GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS' ); /** * Allowed resource methods * @var str[] */ public $allowedMethods = array(); /** * HTTP request method of incoming request * @var str */ public $method = 'GET'; /** * Body data of incoming request * @var str */ public $data; /** * Array of if-match etags * @var str[] */ public $ifMatch = array(); /** * Array of if-none-match etags * @var str[] */ public $ifNoneMatch = array(); /** * The resource classes loaded and how they are wired to URIs * @var str[] */ public $resources = array(); /** * A list of URL to namespace/package mappings for routing requests to a * group of resources that are wired into a different URL-space * @var str[] */ public $mounts = array(); /** * Set a default configuration option */ protected function getConfig($config, $configVar, $serverVar = NULL, $default = NULL) { if (isset($config[$configVar])) { return $config[$configVar]; } elseif (isset($_SERVER[$serverVar]) && $_SERVER[$serverVar] != '') { return $_SERVER[$serverVar]; } else { return $default; } } /** * Create a request object using the given configuration options. * * The configuration options array can contain the following: * *
*
uri
The URI of the request
*
method
The HTTP method of the request
*
data
The body data of the request
*
accept
An accept header
*
acceptLang
An accept-language header
*
acceptEncoding
An accept-encoding header
*
ifMatch
An if-match header
*
ifNoneMatch
An if-none-match header
*
mimetypes
A map of file/URI extenstions to mimetypes, these * will be added to the default map of mimetypes
*
baseUri
The base relative URI to use when dispatcher isn't * at the root of the domain. Do not put a trailing slash
*
mounts
an array of namespace to baseUri prefix mappings
*
HTTPMethods
an array of HTTP methods to support
*
* * @param mixed[] config Configuration options */ function __construct($config = array()) { // set defaults $config['uri'] = parse_url($this->getConfig($config, 'uri', 'REQUEST_URI'), PHP_URL_PATH); $config['baseUri'] = $this->getConfig($config, 'baseUri', ''); $config['accept'] = $this->getConfig($config, 'accept', 'HTTP_ACCEPT'); $config['acceptLang'] = $this->getConfig($config, 'acceptLang', 'HTTP_ACCEPT_LANGUAGE'); $config['acceptEncoding'] = $this->getConfig($config, 'acceptEncoding', 'HTTP_ACCEPT_ENCODING'); $config['ifMatch'] = $this->getConfig($config, 'ifMatch', 'HTTP_IF_MATCH'); $config['ifNoneMatch'] = $this->getConfig($config, 'ifNoneMatch', 'HTTP_IF_NONE_MATCH'); if (isset($config['mimetypes']) && is_array($config['mimetypes'])) { foreach ($config['mimetypes'] as $ext => $mimetype) { $this->mimetypes[$ext] = $mimetype; } } // set baseUri $this->baseUri = $config['baseUri']; // get request URI $parts = explode('/', $config['uri']); $lastPart = array_pop($parts); $this->uri = join('/', $parts); $parts = explode('.', $lastPart); $this->uri .= '/'.$parts[0]; if ($this->uri != '/' && substr($this->uri, -1, 1) == '/') { // remove trailing slash problem $this->uri = substr($this->uri, 0, -1); } array_shift($parts); foreach ($parts as $part) { $this->accept[10][] = $part; $this->acceptLang[10][] = $part; } // sort accept headers $accept = explode(',', strtolower($config['accept'])); foreach ($accept as $mimetype) { $parts = explode(';q=', $mimetype); if (isset($parts) && isset($parts[1]) && $parts[1]) { $num = $parts[1] * 10; } else { $num = 10; } $key = array_search($parts[0], $this->mimetypes); if ($key) { $this->accept[$num][] = $key; } } krsort($this->accept); // sort lang accept headers $accept = explode(',', strtolower($config['acceptLang'])); foreach ($accept as $mimetype) { $parts = explode(';q=', $mimetype); if (isset($parts) && isset($parts[1]) && $parts[1]) { $num = $parts[1] * 10; } else { $num = 10; } $this->acceptLang[$num][] = $parts[0]; } krsort($this->acceptLang); // get encoding accept headers if ($config['acceptEncoding']) { foreach (explode(',', $config['acceptEncoding']) as $key => $accept) { $this->acceptEncoding[$key] = trim($accept); } } // create negotiated URI lists from accept headers and request URI foreach ($this->accept as $typeOrder) { foreach ($typeOrder as $type) { if ($type) { foreach ($this->acceptLang as $langOrder) { foreach ($langOrder as $lang) { if ($lang && $lang != $type) { $this->negotiatedUris[] = $this->uri.'.'.$type.'.'.$lang; } } } $this->negotiatedUris[] = $this->uri.'.'.$type; $this->formatNegotiatedUris[] = $this->uri.'.'.$type; } } } foreach ($this->acceptLang as $langOrder) { foreach ($langOrder as $lang) { if ($lang) { $this->negotiatedUris[] = $this->uri.'.'.$lang; $this->languageNegotiatedUris[] = $this->uri.'.'.$lang; } } } $this->negotiatedUris[] = $this->uri; $this->formatNegotiatedUris[] = $this->uri; $this->languageNegotiatedUris[] = $this->uri; $this->negotiatedUris = array_values(array_unique($this->negotiatedUris)); $this->formatNegotiatedUris = array_values(array_unique($this->formatNegotiatedUris)); $this->languageNegotiatedUris = array_values(array_unique($this->languageNegotiatedUris)); $this->HTTPMethods = $this->getConfig($config, 'HTTPMethods', NULL, $this->HTTPMethods); // get HTTP method $this->method = strtoupper($this->getConfig($config, 'method', 'REQUEST_METHOD', $this->method)); // get HTTP request data $this->data = $this->getConfig($config, 'data', NULL, file_get_contents("php://input")); // conditional requests if ($config['ifMatch']) { $ifMatch = explode(',', $config['ifMatch']); foreach ($ifMatch as $etag) { $this->ifMatch[] = trim($etag, '" '); } } if ($config['ifNoneMatch']) { $ifNoneMatch = explode(',', $config['ifNoneMatch']); foreach ($ifNoneMatch as $etag) { $this->ifNoneMatch[] = trim($etag, '" '); } } // mounts if (isset($config['mount']) && is_array($config['mount'])) { $this->mounts = $config['mount']; } // prime named resources for autoloading if (isset($config['autoload']) && is_array($config['autoload'])) { foreach ($config['autoload'] as $uri => $className) { $this->resources[$uri] = array( 'class' => $className, 'loaded' => FALSE ); } } // load definitions of already loaded resource classes $resourceClassName = class_exists('Tonic\\Resource') ? 'Tonic\\Resource' : 'Resource'; foreach (get_declared_classes() as $className) { if (is_subclass_of($className, $resourceClassName)) { $resourceDetails = $this->getResourceClassDetails($className); preg_match_all('/@uri\s+([^\s]+)(?:\s([0-9]+))?/', $resourceDetails['comment'], $annotations); if (isset($annotations[1]) && $annotations[1]) { $uris = $annotations[1]; } else { $uris = array(); } foreach ($uris as $index => $uri) { if ($uri != '/' && substr($uri, -1, 1) == '/') { // remove trailing slash problem $uri = substr($uri, 0, -1); } if (isset($annotations[2][$index]) && is_numeric($annotations[2][$index])) { $priority = intval($annotations[2][$index]); } else { $priority = 0; } if ( !isset($this->resources[$resourceDetails['mountPoint'].$uri]) || $this->resources[$resourceDetails['mountPoint'].$uri]['priority'] < $priority ) { $this->resources[$resourceDetails['mountPoint'].$uri] = array( 'namespace' => $resourceDetails['namespaceName'], 'class' => $resourceDetails['className'], 'filename' => $resourceDetails['filename'], 'line' => $resourceDetails['line'], 'priority' => $priority, 'loaded' => TRUE ); } } } } } /** * Get the details of a Resource class by reflection * @param str className * @return str[] */ protected function getResourceClassDetails($className) { $resourceReflector = new ReflectionClass($className); $comment = $resourceReflector->getDocComment(); $className = $resourceReflector->getName(); if (method_exists($resourceReflector, 'getNamespaceName')) { $namespaceName = $resourceReflector->getNamespaceName(); } else { // @codeCoverageIgnoreStart $namespaceName = FALSE; // @codeCoverageIgnoreEnd } if (!$namespaceName) { preg_match('/@(?:package|namespace)\s+([^\s]+)/', $comment, $package); if (isset($package[1])) { $namespaceName = $package[1]; } } // adjust URI for mountpoint if (isset($this->mounts[$namespaceName])) { $mountPoint = $this->mounts[$namespaceName]; } else { $mountPoint = ''; } return array( 'comment' => $comment, 'className' => $className, 'namespaceName' => $namespaceName, 'filename' => $resourceReflector->getFileName(), 'line' => $resourceReflector->getStartLine(), 'mountPoint' => $mountPoint ); } /** * Convert the object into a string suitable for printing * @return str * @codeCoverageIgnore */ function __toString() { $str = 'URI: '.$this->uri."\n"; $str .= 'Method: '.$this->method."\n"; if ($this->data) { $str .= 'Data: '.$this->data."\n"; } $str .= 'Acceptable Formats:'; foreach ($this->accept as $accept) { foreach ($accept as $a) { $str .= ' .'.$a; if (isset($this->mimetypes[$a])) $str .= ' ('.$this->mimetypes[$a].')'; } } $str .= "\n"; $str .= 'Acceptable Languages:'; foreach ($this->acceptLang as $accept) { foreach ($accept as $a) { $str .= ' '.$a; } } $str .= "\n"; $str .= 'Negotated URIs:'."\n"; foreach ($this->negotiatedUris as $uri) { $str .= "\t".$uri."\n"; } $str .= 'Format Negotated URIs:'."\n"; foreach ($this->formatNegotiatedUris as $uri) { $str .= "\t".$uri."\n"; } $str .= 'Language Negotated URIs:'."\n"; foreach ($this->languageNegotiatedUris as $uri) { $str .= "\t".$uri."\n"; } if ($this->ifMatch) { $str .= 'If Match:'; foreach ($this->ifMatch as $etag) { $str .= ' '.$etag; } $str .= "\n"; } if ($this->ifNoneMatch) { $str .= 'If None Match:'; foreach ($this->ifNoneMatch as $etag) { $str .= ' '.$etag; } $str .= "\n"; } $str .= 'Loaded Resources:'."\n"; foreach ($this->resources as $uri => $resource) { $str .= "\t".$uri."\n"; if (isset($resource['namespace']) && $resource['namespace']) $str .= "\t\tNamespace: ".$resource['namespace']."\n"; $str .= "\t\tClass: ".$resource['class']."\n"; $str .= "\t\tFile: ".$resource['filename']; if (isset($resource['line']) && $resource['line']) $str .= '#'.$resource['line']; $str .= "\n"; } return $str; } /** * Instantiate the resource class that matches the request URI the best * @return Resource * @throws ResponseException If the resource does not exist, a 404 exception is thrown */ function loadResource() { $uriMatches = array(); foreach ($this->resources as $uri => $resource) { preg_match_all('#((?baseUri.$uri; if ($uri != '/' && substr($uri, -1, 1) == '/') { // remove trailing slash problem $uri = substr($uri, 0, -1); } $uriRegex = preg_replace('#((?uri, $matches)) { array_shift($matches); if (isset($params[1])) { foreach ($params[1] as $index => $param) { if (isset($matches[$index])) { if (substr($param, 0, 1) == ':') { $matches[substr($param, 1)] = $matches[$index]; unset($matches[$index]); } elseif (substr($param, 0, 1) == '{' && substr($param, -1, 1) == '}') { $matches[substr($param, 1, -1)] = $matches[$index]; unset($matches[$index]); } } } } $uriMatches[isset($resource['priority']) ? $resource['priority'] : 0] = array( $uri, $resource, $matches ); } } krsort($uriMatches); if ($uriMatches) { list($uri, $resource, $parameters) = array_shift($uriMatches); if (!$resource['loaded']) { // autoload //echo $resource['class']; if (!class_exists($resource['class'])) { throw new Exception('Unable to load resource'); } $resourceDetails = $this->getResourceClassDetails($resource['class']); $resource = $this->resources[$uri] = array( 'namespace' => $resourceDetails['namespaceName'], 'class' => $resourceDetails['className'], 'filename' => $resourceDetails['filename'], 'line' => $resourceDetails['line'], 'priority' => 0, 'loaded' => TRUE ); } $this->allowedMethods = array_intersect(array_map('strtoupper', get_class_methods($resource['class'])), $this->HTTPMethods); return new $resource['class']($parameters); } // no resource found, throw response exception throw new ResponseException('A resource matching URI "'.$this->uri.'" was not found', Response::NOTFOUND); } /** * Check if an etag matches the requests if-match header * @param str etag Etag to match * @return bool */ function ifMatch($etag) { if (isset($this->ifMatch[0]) && $this->ifMatch[0] == '*') { return TRUE; } return in_array($etag, $this->ifMatch); } /** * Check if an etag matches the requests if-none-match header * @param str etag Etag to match * @return bool */ function ifNoneMatch($etag) { if (isset($this->ifMatch[0]) && $this->ifMatch[0] == '*') { return FALSE; } return in_array($etag, $this->ifNoneMatch); } /** * Return the most acceptable of the given formats based on the accept array * @param str[] formats * @param str default The default format if the requested format does not match $formats * @return str */ function mostAcceptable($formats, $default = NULL) { foreach (call_user_func_array('array_merge', $this->accept) as $format) { if (in_array($format, $formats)) { return $format; } } return $default; } } /** * Base resource class * @namespace Tonic\Lib */ class Resource { private $parameters; /** * Resource constructor * @param str[] parameters Parameters passed in from the URL as matched from the URI regex */ function __construct($parameters) { $this->parameters = $parameters; } /** * Convert the object into a string suitable for printing * @return str * @codeCoverageIgnore */ function __toString() { $str = get_class($this); foreach ($this->parameters as $name => $value) { $str .= "\n".$name.': '.$value; } return $str; } /** * Execute a request on this resource. * @param Request request The request to execute the resource in the context of * @return Response * @throws ResponseException If the HTTP method is not allowed on the resource, a 405 exception is thrown */ function exec($request) { if ( in_array(strtoupper($request->method), $request->HTTPMethods) && method_exists($this, $request->method) ) { $method = new ReflectionMethod($this, $request->method); $parameters = array(); foreach ($method->getParameters() as $param) { if ($param->name == 'request') { $parameters[] = $request; } elseif (isset($this->parameters[$param->name])) { $parameters[] = $this->parameters[$param->name]; unset($this->parameters[$param->name]); } else { $parameters[] = reset($this->parameters); array_shift($this->parameters); } } $response = call_user_func_array( array($this, $request->method), $parameters ); $responseClassName = class_exists('Tonic\\Response') ? 'Tonic\\Response' : 'Response'; if (!$response || !($response instanceof $responseClassName)) { throw new Exception('Method '.$request->method.' of '.get_class($this).' did not return a Response object'); } } else { // send 405 method not allowed throw new ResponseException( 'The HTTP method "'.$request->method.'" is not allowed for the resource "'.$request->uri.'".', Response::METHODNOTALLOWED ); } # good for debugging, remove this at some point $response->addHeader('X-Resource', get_class($this)); return $response; } } /** * Model the data of the outgoing HTTP response * @namespace Tonic\Lib */ class Response { /** * HTTP response code constant */ const OK = 200, CREATED = 201, NOCONTENT = 204, MOVEDPERMANENTLY = 301, FOUND = 302, SEEOTHER = 303, NOTMODIFIED = 304, TEMPORARYREDIRECT = 307, BADREQUEST = 400, UNAUTHORIZED = 401, FORBIDDEN = 403, NOTFOUND = 404, METHODNOTALLOWED = 405, NOTACCEPTABLE = 406, GONE = 410, LENGTHREQUIRED = 411, PRECONDITIONFAILED = 412, UNSUPPORTEDMEDIATYPE = 415, INTERNALSERVERERROR = 500; /** * The request object generating this response * @var Request */ public $request; /** * The HTTP response code to send * @var int */ public $code = Response::OK; /** * The HTTP headers to send * @var str[] */ public $headers = array(); /** * The HTTP response body to send * @var str */ public $body; /** * Create a response object. * @param Request request The request object generating this response * @param str uri The URL of the actual resource being used to build the response */ function __construct($request, $uri = NULL) { $this->request = $request; if ($uri && $uri != $request->uri) { // add content location header $this->addHeader('Content-Location', $uri); $this->addVary('Accept'); $this->addVary('Accept-Language'); } $this->addHeader('Allow', implode(', ', $request->allowedMethods)); } /** * Convert the object into a string suitable for printing * @return str * @codeCoverageIgnore */ function __toString() { $str = 'HTTP/1.1 '.$this->code; foreach ($this->headers as $name => $value) { $str .= "\n".$name.': '.$value; } return $str; } /** * Add a header to the response * @param str header * @param str value */ function addHeader($header, $value) { $this->headers[$header] = $value; } /** * Send a cache control header with the response * @param int time Cache length in seconds */ function addCacheHeader($time = 86400) { if ($time) { $this->addHeader('Cache-Control', 'max-age='.$time.', must-revalidate'); } else { $this->addHeader('Cache-Control', 'no-cache'); } } /** * Send an etag with the response * @param str etag Etag value */ function addEtag($etag) { $this->addHeader('Etag', '"'.$etag.'"'); } function addVary($header) { if (isset($this->headers['Vary'])) { $this->headers['Vary'] .= ', '.$header; } else { $this->addHeader('Vary', $header); } } /** * Output the response * @codeCoverageIgnore */ function output() { if (php_sapi_name() != 'cli' && !headers_sent()) { header('HTTP/1.1 '.$this->code); foreach ($this->headers as $header => $value) { header($header.': '.$value); } } if (strtoupper($this->request->method) !== 'HEAD') { echo $this->body; } } } /** * Exception class for HTTP response errors * @namespace Tonic\Lib */ class ResponseException extends Exception { /** * Generate a default response for this exception * @param Request request * @return Response */ function response($request) { $response = new Response($request); $response->code = $this->code; $response->body = $this->message; return $response; } }