2024-09-06 20:28:06 +08:00

823 lines
31 KiB
PHP

<?php
namespace Luracast\Restler;
use Luracast\Restler\Data\ApiMethodInfo;
use Luracast\Restler\Data\Text;
use ReflectionClass;
use ReflectionMethod;
use ReflectionProperty;
use Exception;
/**
* Router class that routes the urls to api methods along with parameters
*
* @category Framework
* @package Restler
* @author R.Arul Kumaran <arul@luracast.com>
* @copyright 2010 Luracast
* @license http://www.opensource.org/licenses/lgpl-license.php LGPL
* @link http://luracast.com/products/restler/
*
*/
class Routes
{
public static $prefixingParameterNames = array(
'id'
);
public static $fieldTypesByName = array(
'email' => 'email',
'password' => 'password',
'phone' => 'tel',
'mobile' => 'tel',
'tel' => 'tel',
'search' => 'search',
'date' => 'date',
'created_at' => 'datetime',
'modified_at' => 'datetime',
'url' => 'url',
'link' => 'url',
'href' => 'url',
'website' => 'url',
'color' => 'color',
'colour' => 'color',
);
protected static $routes = array();
protected static $models = array();
/**
* Route the public and protected methods of an Api class
*
* @param string $className
* @param string $resourcePath
* @param int $version
*
* @throws RestException
*/
public static function addAPIClass($className, $resourcePath = '', $version = 1)
{
/*
* Mapping Rules
* =============
*
* - Optional parameters should not be mapped to URL
* - If a required parameter is of primitive type
* - If one of the self::$prefixingParameterNames
* - Map it to URL
* - Else If request method is POST/PUT/PATCH
* - Map it to body
* - Else If request method is GET/DELETE
* - Map it to body
* - If a required parameter is not primitive type
* - Do not include it in URL
*/
$class = new ReflectionClass($className);
$dataName = CommentParser::$embeddedDataName;
try {
$classMetadata = CommentParser::parse($class->getDocComment());
} catch (Exception $e) {
throw new RestException(500, "Error while parsing comments of `$className` class. " . $e->getMessage());
}
$classMetadata['scope'] = $scope = static::scope($class);
$methods = $class->getMethods(ReflectionMethod::IS_PUBLIC +
ReflectionMethod::IS_PROTECTED);
foreach ($methods as $method) {
$methodUrl = strtolower($method->getName());
//method name should not begin with _
if ($methodUrl[0] == '_') {
continue;
}
$doc = $method->getDocComment();
try {
$metadata = CommentParser::parse($doc) + $classMetadata;
} catch (Exception $e) {
throw new RestException(500, "Error while parsing comments of `{$className}::{$method->getName()}` method. " . $e->getMessage());
}
//@access should not be private
if (isset($metadata['access'])
&& $metadata['access'] == 'private'
) {
continue;
}
$arguments = array();
$defaults = array();
$params = $method->getParameters();
$position = 0;
$pathParams = array();
$allowAmbiguity
= (isset($metadata['smart-auto-routing'])
&& $metadata['smart-auto-routing'] != 'true')
|| !Defaults::$smartAutoRouting;
$metadata['resourcePath'] = trim($resourcePath, '/');
if (isset($classMetadata['description'])) {
$metadata['classDescription'] = $classMetadata['description'];
}
if (isset($classMetadata['classLongDescription'])) {
$metadata['classLongDescription']
= $classMetadata['longDescription'];
}
if (!isset($metadata['param'])) {
$metadata['param'] = array();
}
if (isset($metadata['return']['type'])) {
if ($qualified = Scope::resolve($metadata['return']['type'], $scope))
list($metadata['return']['type'], $metadata['return']['children']) =
static::getTypeAndModel(new ReflectionClass($qualified), $scope);
} else {
//assume return type is array
$metadata['return']['type'] = 'array';
}
foreach ($params as $param) {
$children = array();
$type =
$param->isArray() ? 'array' : $param->getClass();
$arguments[$param->getName()] = $position;
$defaults[$position] = $param->isDefaultValueAvailable() ?
$param->getDefaultValue() : null;
if (!isset($metadata['param'][$position])) {
$metadata['param'][$position] = array();
}
$m = & $metadata ['param'] [$position];
$m ['name'] = $param->getName();
if (!isset($m[$dataName])) {
$m[$dataName] = array();
}
$p = &$m[$dataName];
if (empty($m['label']))
$m['label'] = Text::title($m['name']);
if (is_null($type) && isset($m['type'])) {
$type = $m['type'];
}
if (isset(static::$fieldTypesByName[$m['name']]) && empty($p['type']) && $type == 'string') {
$p['type'] = static::$fieldTypesByName[$m['name']];
}
$m ['default'] = $defaults [$position];
$m ['required'] = !$param->isOptional();
$contentType = Util::nestedValue($p,'type');
if ($type == 'array' && $contentType && $qualified = Scope::resolve($contentType, $scope)) {
list($p['type'], $children, $modelName) = static::getTypeAndModel(
new ReflectionClass($qualified), $scope,
$className . Text::title($methodUrl), $p
);
}
if ($type instanceof ReflectionClass) {
list($type, $children, $modelName) = static::getTypeAndModel($type, $scope,
$className . Text::title($methodUrl), $p);
} elseif ($type && is_string($type) && $qualified = Scope::resolve($type, $scope)) {
list($type, $children, $modelName)
= static::getTypeAndModel(new ReflectionClass($qualified), $scope,
$className . Text::title($methodUrl), $p);
}
if (isset($type)) {
$m['type'] = $type;
}
$m['children'] = $children;
if (isset($modelName)) {
$m['model'] = $modelName;
}
if ($m['name'] == Defaults::$fullRequestDataName) {
$from = 'body';
if (!isset($m['type'])) {
$type = $m['type'] = 'array';
}
} elseif (isset($p['from'])) {
$from = $p['from'];
} else {
if ((isset($type) && Util::isObjectOrArray($type))
) {
$from = 'body';
if (!isset($type)) {
$type = $m['type'] = 'array';
}
} elseif ($m['required'] && in_array($m['name'], static::$prefixingParameterNames)) {
$from = 'path';
} else {
$from = 'body';
}
}
$p['from'] = $from;
if (!isset($m['type'])) {
$type = $m['type'] = static::type($defaults[$position]);
}
if ($allowAmbiguity || $from == 'path') {
$pathParams [] = $position;
}
$position++;
}
$accessLevel = 0;
if ($method->isProtected()) {
$accessLevel = 3;
} elseif (isset($metadata['access'])) {
if ($metadata['access'] == 'protected') {
$accessLevel = 2;
} elseif ($metadata['access'] == 'hybrid') {
$accessLevel = 1;
}
} elseif (isset($metadata['protected'])) {
$accessLevel = 2;
}
/*
echo " access level $accessLevel for $className::"
.$method->getName().$method->isProtected().PHP_EOL;
*/
// take note of the order
$call = array(
'url' => null,
'className' => $className,
'path' => rtrim($resourcePath, '/'),
'methodName' => $method->getName(),
'arguments' => $arguments,
'defaults' => $defaults,
'metadata' => $metadata,
'accessLevel' => $accessLevel,
);
// if manual route
if (preg_match_all(
'/@url\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)'
. '[ \t]*\/?(\S*)/s',
$doc, $matches, PREG_SET_ORDER
)
) {
foreach ($matches as $match) {
$httpMethod = $match[1];
$url = rtrim($resourcePath . $match[2], '/');
//deep copy the call, as it may change for each @url
$copy = unserialize(serialize($call));
foreach ($copy['metadata']['param'] as $i => $p) {
$inPath =
strpos($url, '{' . $p['name'] . '}') ||
strpos($url, ':' . $p['name']);
if ($inPath) {
$copy['metadata']['param'][$i][$dataName]['from'] = 'path';
} elseif ($httpMethod == 'GET' || $httpMethod == 'DELETE') {
$copy['metadata']['param'][$i][$dataName]['from'] = 'query';
} elseif (empty($p[$dataName]['from']) || $p[$dataName]['from'] == 'path') {
$copy['metadata']['param'][$i][$dataName]['from'] = 'body';
}
}
$url = preg_replace_callback('/{[^}]+}|:[^\/]+/',
function ($matches) use ($copy) {
$match = trim($matches[0], '{}:');
$index = $copy['arguments'][$match];
return '{' .
Routes::typeChar(isset(
$copy['metadata']['param'][$index]['type'])
? $copy['metadata']['param'][$index]['type']
: null)
. $index . '}';
}, $url);
static::addPath($url, $copy, $httpMethod, $version);
}
//if auto route enabled, do so
} elseif (Defaults::$autoRoutingEnabled) {
// no configuration found so use convention
if (preg_match_all(
'/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)/i',
$methodUrl, $matches)
) {
$httpMethod = strtoupper($matches[0][0]);
$methodUrl = substr($methodUrl, strlen($httpMethod));
} else {
$httpMethod = 'GET';
}
if ($methodUrl == 'index') {
$methodUrl = '';
}
$url = empty($methodUrl) ? rtrim($resourcePath, '/')
: $resourcePath . $methodUrl;
for ($position = 0; $position < count($params); $position++) {
$from = $metadata['param'][$position][$dataName]['from'];
if ($from == 'body' && ($httpMethod == 'GET' ||
$httpMethod == 'DELETE')
) {
$call['metadata']['param'][$position][$dataName]['from']
= 'query';
}
}
if (empty($pathParams) || $allowAmbiguity) {
static::addPath($url, $call, $httpMethod, $version);
}
$lastPathParam = end($pathParams);
foreach ($pathParams as $position) {
if (!empty($url))
$url .= '/';
$url .= '{' .
static::typeChar(isset($call['metadata']['param'][$position]['type'])
? $call['metadata']['param'][$position]['type']
: null)
. $position . '}';
if ($allowAmbiguity || $position == $lastPathParam) {
static::addPath($url, $call, $httpMethod, $version);
}
}
}
}
}
/**
* @access private
*/
public static function typeChar($type = null)
{
if (!$type) {
return 's';
}
switch ($type[0]) {
case 'i':
case 'f':
return 'n';
}
return 's';
}
protected static function addPath($path, array $call,
$httpMethod = 'GET', $version = 1)
{
$call['url'] = preg_replace_callback(
"/\{\S(\d+)\}/",
function ($matches) use ($call) {
return '{' .
$call['metadata']['param'][$matches[1]]['name'] . '}';
},
$path
);
//check for wildcard routes
if (substr($path, -1, 1) == '*') {
$path = rtrim($path, '/*');
static::$routes["v$version"]['*'][$path][$httpMethod] = $call;
} else {
static::$routes["v$version"][$path][$httpMethod] = $call;
//create an alias with index if the method name is index
if ($call['methodName'] == 'index')
static::$routes["v$version"][ltrim("$path/index", '/')][$httpMethod] = $call;
}
}
/**
* Find the api method for the given url and http method
*
* @param string $path Requested url path
* @param string $httpMethod GET|POST|PUT|PATCH|DELETE etc
* @param int $version Api Version number
* @param array $data Data collected from the request
*
* @throws RestException
* @return ApiMethodInfo
*/
public static function find($path, $httpMethod,
$version = 1, array $data = array())
{
$p = Util::nestedValue(static::$routes, "v$version");
if (!$p) {
throw new RestException(
404,
$version == 1 ? '' : "Version $version is not supported"
);
}
$status = 404;
$message = null;
$methods = array();
if (isset($p[$path][$httpMethod])) {
//================== static routes ==========================
return static::populate($p[$path][$httpMethod], $data);
} elseif (isset($p['*'])) {
//================== wildcard routes ========================
uksort($p['*'], function ($a, $b) {
return strlen($b) - strlen($a);
});
foreach ($p['*'] as $key => $value) {
if (strpos($path, $key) === 0 && isset($value[$httpMethod])) {
//path found, convert rest of the path to parameters
$path = substr($path, strlen($key) + 1);
$call = ApiMethodInfo::__set_state($value[$httpMethod]);
$call->parameters = empty($path)
? array()
: explode('/', $path);
return $call;
}
}
}
//================== dynamic routes =============================
//add newline char if trailing slash is found
if (substr($path, -1) == '/')
$path .= PHP_EOL;
//if double slash is found fill in newline char;
$path = str_replace('//', '/' . PHP_EOL . '/', $path);
ksort($p);
foreach ($p as $key => $value) {
if (!isset($value[$httpMethod])) {
continue;
}
$regex = str_replace(array('{', '}'),
array('(?P<', '>[^/]+)'), $key);
if (preg_match_all(":^$regex$:i", $path, $matches, PREG_SET_ORDER)) {
$matches = $matches[0];
$found = true;
foreach ($matches as $k => $v) {
if (is_numeric($k)) {
unset($matches[$k]);
continue;
}
$index = intval(substr($k, 1));
$details = $value[$httpMethod]['metadata']['param'][$index];
if ($k[0] == 's' || strpos($k, static::pathVarTypeOf($v)) === 0) {
//remove the newlines
$data[$details['name']] = trim($v, PHP_EOL);
} else {
$status = 400;
$message = 'invalid value specified for `'
. $details['name'] . '`';
$found = false;
break;
}
}
if ($found) {
return static::populate($value[$httpMethod], $data);
}
}
}
if ($status == 404) {
//check if other methods are allowed
if (isset($p[$path])) {
$status = 405;
$methods = array_keys($p[$path]);
}
}
if ($status == 405) {
header('Allow: ' . implode(', ', $methods));
}
throw new RestException($status, $message);
}
public static function findAll(array $excludedPaths = array(), array $excludedHttpMethods = array(), $version = 1)
{
$map = array();
$all = Util::nestedValue(self::$routes, "v$version");
$filter = array();
if (isset($all['*'])) {
$all = $all['*'] + $all;
unset($all['*']);
}
if(is_array($all)){
foreach ($all as $fullPath => $routes) {
foreach ($routes as $httpMethod => $route) {
if (in_array($httpMethod, $excludedHttpMethods)) {
continue;
}
foreach ($excludedPaths as $exclude) {
if (empty($exclude)) {
if ($fullPath == $exclude || $fullPath == 'index')
continue 2;
} elseif (Text::beginsWith($fullPath, $exclude)) {
continue 2;
}
}
$hash = "$httpMethod " . $route['url'];
if (!isset($filter[$hash])) {
$route['httpMethod'] = $httpMethod;
$map[$route['metadata']['resourcePath']][]
= array('access' => static::verifyAccess($route), 'route' => $route, 'hash' => $hash);
$filter[$hash] = true;
}
}
}
}
return $map;
}
public static function verifyAccess($route)
{
if ($route['accessLevel'] < 2)
return true;
/** @var Restler $r */
$r = Scope::get('Restler');
$authenticated = $r->_authenticated;
if (!$authenticated && $route['accessLevel'] > 1)
return false;
if (
$authenticated &&
Defaults::$accessControlFunction &&
(!call_user_func(Defaults::$accessControlFunction, $route['metadata']))
) {
return false;
}
return true;
}
/**
* Populates the parameter values
*
* @param array $call
* @param $data
*
* @return ApiMethodInfo
*
* @access private
*/
protected static function populate(array $call, $data)
{
$call['parameters'] = $call['defaults'];
$p = & $call['parameters'];
$dataName = CommentParser::$embeddedDataName;
foreach ($data as $key => $value) {
if (isset($call['arguments'][$key])) {
$p[$call['arguments'][$key]] = $value;
}
}
if (Defaults::$smartParameterParsing) {
if (
($m = Util::nestedValue($call, 'metadata', 'param', 0)) &&
!array_key_exists($m['name'], $data) &&
array_key_exists(Defaults::$fullRequestDataName, $data) &&
!is_null($d = $data[Defaults::$fullRequestDataName]) &&
isset($m['type']) &&
static::typeMatch($m['type'], $d)
) {
$p[0] = $d;
} else {
$bodyParamCount = 0;
$lastBodyParamIndex = -1;
$lastM = null;
foreach ($call['metadata']['param'] as $k => $m) {
if ($m[$dataName]['from'] == 'body') {
$bodyParamCount++;
$lastBodyParamIndex = $k;
$lastM = $m;
}
}
if (
$bodyParamCount == 1 &&
!array_key_exists($lastM['name'], $data) &&
array_key_exists(Defaults::$fullRequestDataName, $data) &&
!is_null($d = $data[Defaults::$fullRequestDataName])
) {
$p[$lastBodyParamIndex] = $d;
}
}
}
$r = ApiMethodInfo::__set_state($call);
$modifier = "_modify_{$r->methodName}_api";
if (method_exists($r->className, $modifier)) {
$stage = end(Scope::get('Restler')->getEvents());
if (empty($stage))
$stage = 'setup';
$r = Scope::get($r->className)->$modifier($r, $stage) ? : $r;
}
return $r;
}
/**
* @access private
*/
protected static function pathVarTypeOf($var)
{
if (is_numeric($var)) {
return 'n';
}
if ($var === 'true' || $var === 'false') {
return 'b';
}
return 's';
}
protected static function typeMatch($type, $var)
{
switch ($type) {
case 'boolean':
case 'bool':
return is_bool($var);
case 'array':
case 'object':
return is_array($var);
case 'string':
case 'int':
case 'integer':
case 'float':
case 'number':
return is_scalar($var);
}
return true;
}
protected static function parseMagic(ReflectionClass $class, $forResponse = true)
{
if (!$c = CommentParser::parse($class->getDocComment())) {
return false;
}
$p = 'property';
$r = empty($c[$p]) ? array() : $c[$p];
$p .= '-' . ($forResponse ? 'read' : 'write');
if (!empty($c[$p])) {
$r = array_merge($r, $c[$p]);
}
return $r;
}
/**
* Get the type and associated model
*
* @param ReflectionClass $class
* @param array $scope
*
* @throws RestException
* @throws \Exception
* @return array
*
* @access protected
*/
protected static function getTypeAndModel(ReflectionClass $class, array $scope, $prefix='', array $rules=array())
{
$className = $class->getName();
$dataName = CommentParser::$embeddedDataName;
if (isset(static::$models[$prefix.$className])) {
return static::$models[$prefix.$className];
}
$children = array();
try {
if ($magic_properties = static::parseMagic($class, empty($prefix))) {
foreach ($magic_properties as $prop) {
if (!isset($prop['name'])) {
throw new Exception('@property comment is not properly defined in ' . $className . ' class');
}
if (!isset($prop[$dataName]['label'])) {
$prop[$dataName]['label'] = Text::title($prop['name']);
}
if (isset(static::$fieldTypesByName[$prop['name']]) && $prop['type'] == 'string' && !isset($prop[$dataName]['type'])) {
$prop[$dataName]['type'] = static::$fieldTypesByName[$prop['name']];
}
$children[$prop['name']] = $prop;
}
} else {
$props = $class->getProperties(ReflectionProperty::IS_PUBLIC);
foreach ($props as $prop) {
$name = $prop->getName();
$child = array('name' => $name);
if ($c = $prop->getDocComment()) {
$child += Util::nestedValue(CommentParser::parse($c), 'var') ?: array();
} else {
$o = $class->newInstance();
$p = $prop->getValue($o);
if (is_object($p)) {
$child['type'] = get_class($p);
} elseif (is_array($p)) {
$child['type'] = 'array';
if (count($p)) {
$pc = reset($p);
if (is_object($pc)) {
$child['contentType'] = get_class($pc);
}
}
}
}
$child += array(
'type' => isset(static::$fieldTypesByName[$child['name']])
? static::$fieldTypesByName[$child['name']]
: 'string',
'label' => Text::title($child['name'])
);
isset($child[$dataName])
? $child[$dataName] += array('required' => true)
: $child[$dataName]['required'] = true;
if ($prop->class != $className && $qualified = Scope::resolve($child['type'], $scope)) {
list($child['type'], $child['children'])
= static::getTypeAndModel(new ReflectionClass($qualified), $scope);
} elseif (
($contentType = Util::nestedValue($child, $dataName, 'type')) &&
($qualified = Scope::resolve($contentType, $scope))
) {
list($child['contentType'], $child['children'])
= static::getTypeAndModel(new ReflectionClass($qualified), $scope);
}
$children[$name] = $child;
}
}
} catch (Exception $e) {
if (Text::endsWith($e->getFile(), 'CommentParser.php')) {
throw new RestException(500, "Error while parsing comments of `$className` class. " . $e->getMessage());
}
throw $e;
}
if ($properties = Util::nestedValue($rules, 'properties')) {
if (is_string($properties)) {
$properties = array($properties);
}
$c = array();
foreach ($properties as $property) {
if (isset($children[$property])) {
$c[$property] = $children[$property];
}
}
$children = $c;
}
if ($required = Util::nestedValue($rules, 'required')) {
//override required on children
if (is_bool($required)) {
// true means all are required false means none are required
$required = $required ? array_keys($children) : array();
} elseif (is_string($required)) {
$required = array($required);
}
$required = array_fill_keys($required, true);
foreach ($children as $name => $child) {
$children[$name][$dataName]['required'] = isset($required[$name]);
}
}
static::$models[$prefix.$className] = array($className, $children, $prefix.$className);
return static::$models[$prefix.$className];
}
/**
* Import previously created routes from cache
*
* @param array $routes
*/
public static function fromArray(array $routes)
{
static::$routes = $routes;
}
/**
* Export current routes for cache
*
* @return array
*/
public static function toArray()
{
return static::$routes;
}
public static function type($var)
{
if (is_object($var)) return get_class($var);
if (is_array($var)) return 'array';
if (is_bool($var)) return 'boolean';
if (is_numeric($var)) return is_float($var) ? 'float' : 'int';
return 'string';
}
public static function scope(ReflectionClass $class)
{
$namespace = $class->getNamespaceName();
$imports = array(
'*' => empty($namespace) ? '' : $namespace . '\\'
);
$file = file_get_contents($class->getFileName());
$tokens = token_get_all($file);
$namespace = '';
$alias = '';
$reading = false;
$last = 0;
foreach ($tokens as $token) {
if (is_string($token)) {
if ($reading && ',' == $token) {
//===== STOP =====//
$reading = false;
if (!empty($namespace))
$imports[$alias] = trim($namespace, '\\');
//===== START =====//
$reading = true;
$namespace = '';
$alias = '';
} else {
//===== STOP =====//
$reading = false;
if (!empty($namespace))
$imports[$alias] = trim($namespace, '\\');
}
} elseif (T_USE == $token[0]) {
//===== START =====//
$reading = true;
$namespace = '';
$alias = '';
} elseif ($reading) {
//echo token_name($token[0]) . ' ' . $token[1] . PHP_EOL;
switch ($token[0]) {
case T_WHITESPACE:
continue 2;
case T_STRING:
$alias = $token[1];
if (T_AS == $last) {
break;
}
//don't break;
case T_NS_SEPARATOR:
$namespace .= $token[1];
break;
}
$last = $token[0];
}
}
return $imports;
}
}