* @copyright 2010 Luracast * @license http://www.opensource.org/licenses/lgpl-license.php LGPL * @link http://luracast.com/products/restler/ * */ class Resources implements iUseAuthentication, iProvideMultiVersionApi { /** * @var bool should protected resources be shown to unauthenticated users? */ public static $hideProtected = true; /** * @var bool should we use format as extension? */ public static $useFormatAsExtension = true; /** * @var bool should we include newer apis in the list? works only when * Defaults::$useUrlBasedVersioning is set to true; */ public static $listHigherVersions = true; /** * @var array all http methods specified here will be excluded from * documentation */ public static $excludedHttpMethods = array('OPTIONS'); /** * @var array all paths beginning with any of the following will be excluded * from documentation */ public static $excludedPaths = array(); /** * @var bool */ public static $placeFormatExtensionBeforeDynamicParts = true; /** * @var bool should we group all the operations with the same url or not */ public static $groupOperations = false; /** * @var null|callable if the api methods are under access control mechanism * you can attach a function here that returns true or false to determine * visibility of a protected api method. this function will receive method * info as the only parameter. */ public static $accessControlFunction = null; /** * @var array type mapping for converting data types to javascript / swagger */ public static $dataTypeAlias = array( 'string' => 'string', 'int' => 'int', 'number' => 'float', 'float' => 'float', 'bool' => 'boolean', 'boolean' => 'boolean', 'NULL' => 'null', 'array' => 'Array', 'object' => 'Object', 'stdClass' => 'Object', 'mixed' => 'string', 'DateTime' => 'Date' ); /** * @var array configurable symbols to differentiate public, hybrid and * protected api */ public static $apiDescriptionSuffixSymbols = array( 0 => '  ', //public api 1 => '  ', //hybrid api 2 => '  ', //protected api ); /** * Injected at runtime * * @var Restler instance of restler */ public $restler; /** * @var string when format is not used as the extension this property is * used to set the extension manually */ public $formatString = ''; protected $_models; protected $_bodyParam; /** * @var bool|stdClass */ protected $_fullDataRequested = false; protected $crud = array( 'POST' => 'create', 'GET' => 'retrieve', 'PUT' => 'update', 'DELETE' => 'delete', 'PATCH' => 'partial update' ); protected static $prefixes = array( 'get' => 'retrieve', 'index' => 'list', 'post' => 'create', 'put' => 'update', 'patch' => 'modify', 'delete' => 'remove', ); protected $_authenticated = false; protected $cacheName = ''; public function __construct() { if (static::$useFormatAsExtension) { $this->formatString = '.{format}'; } } /** * This method will be called first for filter classes and api classes so * that they can respond accordingly for filer method call and api method * calls * * * @param bool $isAuthenticated passes true when the authentication is * done, false otherwise * * @return mixed */ public function __setAuthenticationStatus($isAuthenticated = false) { $this->_authenticated = $isAuthenticated; } /** * pre call for get($id) * * if cache is present, use cache */ public function _pre_get_json($id) { $userClass = Defaults::$userIdentifierClass; $this->cacheName = $userClass::getCacheIdentifier() . '_resources_' . $id; if ($this->restler->getProductionMode() && !$this->restler->refreshCache && $this->restler->cache->isCached($this->cacheName) ) { //by pass call, compose, postCall stages and directly send response $this->restler->composeHeaders(); die($this->restler->cache->get($this->cacheName)); } } /** * post call for get($id) * * create cache if in production mode * * @param $responseData * * @internal param string $data composed json output * * @return string */ public function _post_get_json($responseData) { if ($this->restler->getProductionMode()) { $this->restler->cache->set($this->cacheName, $responseData); } return $responseData; } /** * @access hybrid * * @param string $id * * @throws RestException * @return null|stdClass * * @url GET {id} */ public function get($id = '') { $version = $this->restler->getRequestedApiVersion(); if (empty($id)) { //do nothing } elseif (false !== ($pos = strpos($id, '-v'))) { //$version = intval(substr($id, $pos + 2)); $id = substr($id, 0, $pos); } elseif ($id[0] == 'v' && is_numeric($v = substr($id, 1))) { $id = ''; //$version = $v; } elseif ($id == 'root' || $id == 'index') { $id = ''; } $this->_models = new stdClass(); $r = null; $count = 0; $tSlash = !empty($id); $target = empty($id) ? '' : $id; $tLen = strlen($target); $filter = array(); $routes = Util::nestedValue(Routes::toArray(), "v$version") ? : array(); $prefix = Defaults::$useUrlBasedVersioning ? "/v$version" : ''; foreach ($routes as $value) { foreach ($value as $httpMethod => $route) { if (in_array($httpMethod, static::$excludedHttpMethods)) { continue; } $fullPath = $route['url']; if ($fullPath !== $target && !Text::beginsWith($fullPath, $target)) { continue; } $fLen = strlen($fullPath); if ($tSlash) { if ($fLen != $tLen && !Text::beginsWith($fullPath, $target . '/')) continue; } elseif ($fLen > $tLen + 1 && $fullPath[$tLen + 1] != '{' && !Text::beginsWith($fullPath, '{')) { //when mapped to root exclude paths that have static parts //they are listed else where under that static part name continue; } if (!static::verifyAccess($route)) { continue; } foreach (static::$excludedPaths as $exclude) { if (empty($exclude)) { if ($fullPath == $exclude) continue 2; } elseif (Text::beginsWith($fullPath, $exclude)) { continue 2; } } $m = $route['metadata']; if ($id == '' && $m['resourcePath'] != '') { continue; } if (isset($filter[$httpMethod][$fullPath])) { continue; } $filter[$httpMethod][$fullPath] = true; // reset body params $this->_bodyParam = array( 'required' => false, 'description' => array() ); $count++; $className = $this->_noNamespace($route['className']); if (!$r) { $resourcePath = '/' . trim($m['resourcePath'], '/'); $r = $this->_operationListing($resourcePath); } $parts = explode('/', $fullPath); $pos = count($parts) - 1; if (count($parts) == 1 && $httpMethod == 'GET') { } else { for ($i = 0; $i < count($parts); $i++) { if (strlen($parts[$i]) && $parts[$i][0] == '{') { $pos = $i - 1; break; } } } $nickname = $this->_nickname($route); $index = static::$placeFormatExtensionBeforeDynamicParts && $pos > 0 ? $pos : 0; if (!empty($parts[$index])) $parts[$index] .= $this->formatString; $fullPath = implode('/', $parts); $description = isset( $m['classDescription']) ? $m['classDescription'] : $className . ' API'; if (empty($m['description'])) { $m['description'] = $this->restler->getProductionMode() ? '' : 'routes to ' . $route['className'] . '::' . $route['methodName'] . '();'; } if (empty($m['longDescription'])) { $m['longDescription'] = $this->restler->getProductionMode() ? '' : 'Add PHPDoc long description to ' . "$className::" . $route['methodName'] . '();' . ' (the api method) to write here'; } $operation = $this->_operation( $route, $nickname, $httpMethod, $m['description'], $m['longDescription'] ); if (isset($m['throws'])) { foreach ($m['throws'] as $exception) { $operation->errorResponses[] = array( 'reason' => $exception['message'], 'code' => $exception['code']); } } if (isset($m['param'])) { foreach ($m['param'] as $param) { //combine body params as one $p = $this->_parameter($param); if ($p->paramType == 'body') { $this->_appendToBody($p); } else { $operation->parameters[] = $p; } } } if ( count($this->_bodyParam['description']) || ( $this->_fullDataRequested && $httpMethod != 'GET' && $httpMethod != 'DELETE' ) ) { $operation->parameters[] = $this->_getBody(); } if (isset($m['return']['type'])) { $responseClass = $m['return']['type']; if (is_string($responseClass)) { if (class_exists($responseClass)) { $this->_model($responseClass); $operation->responseClass = $this->_noNamespace($responseClass); } elseif (strtolower($responseClass) == 'array') { $operation->responseClass = 'Array'; $rt = $m['return']; if (isset( $rt[CommentParser::$embeddedDataName]['type']) ) { $rt = $rt[CommentParser::$embeddedDataName] ['type']; if (class_exists($rt)) { $this->_model($rt); $operation->responseClass .= '[' . $this->_noNamespace($rt) . ']'; } } } } } $api = false; if (static::$groupOperations) { foreach ($r->apis as $a) { if ($a->path == "$prefix/$fullPath") { $api = $a; break; } } } if (!$api) { $api = $this->_api("$prefix/$fullPath", $description); $r->apis[] = $api; } $api->operations[] = $operation; } } if (!$count) { throw new RestException(404); } if (!is_null($r)) $r->models = $this->_models; usort( $r->apis, function ($a, $b) { $order = array( 'GET' => 1, 'POST' => 2, 'PUT' => 3, 'PATCH' => 4, 'DELETE' => 5 ); return $a->operations[0]->httpMethod == $b->operations[0]->httpMethod ? $a->path > $b->path : $order[$a->operations[0]->httpMethod] > $order[$b->operations[0]->httpMethod]; } ); return $r; } protected function _nickname(array $route) { static $hash = array(); $method = $route['methodName']; if (isset(static::$prefixes[$method])) { $method = static::$prefixes[$method]; } else { $method = str_replace( array_keys(static::$prefixes), array_values(static::$prefixes), $method ); } while (isset($hash[$method]) && $route['url'] != $hash[$method]) { //create another one $method .= '_'; } $hash[$method] = $route['url']; return $method; } protected function _noNamespace($className) { $className = explode('\\', $className); return end($className); } protected function _operationListing($resourcePath = '/') { $r = $this->_resourceListing(); $r->resourcePath = $resourcePath; $r->models = new stdClass(); return $r; } protected function _resourceListing() { $r = new stdClass(); $r->apiVersion = (string)$this->restler->_requestedApiVersion; $r->swaggerVersion = "1.1"; $r->basePath = $this->restler->getBaseUrl(); $r->produces = $this->restler->getWritableMimeTypes(); $r->consumes = $this->restler->getReadableMimeTypes(); $r->apis = array(); return $r; } protected function _api($path, $description = '') { $r = new stdClass(); $r->path = $path; $r->description = empty($description) && $this->restler->getProductionMode() ? 'Use PHPDoc comment to describe here' : $description; $r->operations = array(); return $r; } protected function _operation( $route, $nickname, $httpMethod = 'GET', $summary = 'description', $notes = 'long description', $responseClass = 'void' ) { //reset body params $this->_bodyParam = array( 'required' => false, 'description' => array() ); $r = new stdClass(); $r->httpMethod = $httpMethod; $r->nickname = $nickname; $r->responseClass = $responseClass; $r->parameters = array(); $r->summary = $summary . ($route['accessLevel'] > 2 ? static::$apiDescriptionSuffixSymbols[2] : static::$apiDescriptionSuffixSymbols[$route['accessLevel']] ); $r->notes = $notes; $r->errorResponses = array(); return $r; } protected function _parameter($param) { $r = new stdClass(); $r->name = $param['name']; $r->description = !empty($param['description']) ? $param['description'] . '.' : ($this->restler->getProductionMode() ? '' : 'add @param {type} $' . $r->name . ' {comment} to describe here'); //paramType can be path or query or body or header $r->paramType = Util::nestedValue($param, CommentParser::$embeddedDataName, 'from') ? : 'query'; $r->required = isset($param['required']) && $param['required']; if (isset($param['default'])) { $r->defaultValue = $param['default']; } elseif (isset($param[CommentParser::$embeddedDataName]['example'])) { $r->defaultValue = $param[CommentParser::$embeddedDataName]['example']; } $r->allowMultiple = false; $type = 'string'; if (isset($param['type'])) { $type = $param['type']; if (is_array($type)) { $type = array_shift($type); } if ($type == 'array') { $contentType = Util::nestedValue( $param, CommentParser::$embeddedDataName, 'type' ); if ($contentType) { if ($contentType == 'indexed') { $type = 'Array'; } elseif ($contentType == 'associative') { $type = 'Object'; } else { $type = "Array[$contentType]"; } if (Util::isObjectOrArray($contentType)) { $this->_model($contentType); } } elseif (isset(static::$dataTypeAlias[$type])) { $type = static::$dataTypeAlias[$type]; } } elseif (Util::isObjectOrArray($type)) { $this->_model($type); } elseif (isset(static::$dataTypeAlias[$type])) { $type = static::$dataTypeAlias[$type]; } } $r->dataType = $type; if (isset($param[CommentParser::$embeddedDataName])) { $p = $param[CommentParser::$embeddedDataName]; if (isset($p['min']) && isset($p['max'])) { $r->allowableValues = array( 'valueType' => 'RANGE', 'min' => $p['min'], 'max' => $p['max'], ); } elseif (isset($p['choice'])) { $r->allowableValues = array( 'valueType' => 'LIST', 'values' => $p['choice'] ); } } return $r; } protected function _appendToBody($p) { if ($p->name === Defaults::$fullRequestDataName) { $this->_fullDataRequested = $p; unset($this->_bodyParam['names'][Defaults::$fullRequestDataName]); return; } $this->_bodyParam['description'][$p->name] = "$p->name" . ' : ' . $p->dataType . ' ' . ($p->required ? ' (required) - ' : ' - ') . $p->description; $this->_bodyParam['required'] = $p->required || $this->_bodyParam['required']; $this->_bodyParam['names'][$p->name] = $p; } protected function _getBody() { $r = new stdClass(); $n = isset($this->_bodyParam['names']) ? array_values($this->_bodyParam['names']) : array(); if (count($n) == 1) { if (isset($this->_models->{$n[0]->dataType})) { // ============ custom class =================== $r = $n[0]; $c = $this->_models->{$r->dataType}; $a = $c->properties; $r->description = "Paste JSON data here"; if (count($a)) { $r->description .= " with the following" . (count($a) > 1 ? ' properties.' : ' property.'); foreach ($a as $k => $v) { $r->description .= "
$k : " . $v['type'] . ' ' . (isset($v['required']) ? '(required)' : '') . ' - ' . $v['description']; } } $r->defaultValue = "{\n \"" . implode("\": \"\",\n \"", array_keys($c->properties)) . "\": \"\"\n}"; return $r; } elseif (false !== ($p = strpos($n[0]->dataType, '['))) { // ============ array of custom class =============== $r = $n[0]; $t = substr($r->dataType, $p + 1, -1); if ($c = Util::nestedValue($this->_models, $t)) { $a = $c->properties; $r->description = "Paste JSON data here"; if (count($a)) { $r->description .= " with an array of objects with the following" . (count($a) > 1 ? ' properties.' : ' property.'); foreach ($a as $k => $v) { $r->description .= "
$k : " . $v['type'] . ' ' . (isset($v['required']) ? '(required)' : '') . ' - ' . $v['description']; } } $r->defaultValue = "[\n {\n \"" . implode("\": \"\",\n \"", array_keys($c->properties)) . "\": \"\"\n }\n]"; return $r; } else { $r->description = "Paste JSON data here with an array of $t values."; $r->defaultValue = "[ ]"; return $r; } } elseif ($n[0]->dataType == 'Array') { // ============ array =============================== $r = $n[0]; $r->description = "Paste JSON array data here" . ($r->required ? ' (required) . ' : '. ') . "
$r->description"; $r->defaultValue = "[\n {\n \"" . "property\" : \"\"\n }\n]"; return $r; } elseif ($n[0]->dataType == 'Object') { // ============ object ============================== $r = $n[0]; $r->description = "Paste JSON object data here" . ($r->required ? ' (required) . ' : '. ') . "
$r->description"; $r->defaultValue = "{\n \"" . "property\" : \"\"\n}"; return $r; } } $p = array_values($this->_bodyParam['description']); $r->name = 'REQUEST_BODY'; $r->description = "Paste JSON data here"; if (count($p) == 0 && $this->_fullDataRequested) { $r->required = $this->_fullDataRequested->required; $r->defaultValue = "{\n \"property\" : \"\"\n}"; } else { $r->description .= " with the following" . (count($p) > 1 ? ' properties.' : ' property.') . '
' . implode("
", $p); $r->required = $this->_bodyParam['required']; // Create default object that includes parameters to be submitted $defaultObject = new \StdClass(); foreach ($this->_bodyParam['names'] as $name => $values) { if (!$values->required) continue; if (class_exists($values->dataType)) { $myClassName = $values->dataType; $defaultObject->$name = new $myClassName(); } else { $defaultObject->$name = ''; } } $r->defaultValue = Scope::get('JsonFormat')->encode($defaultObject, true); } $r->paramType = 'body'; $r->allowMultiple = false; $r->dataType = 'Object'; return $r; } protected function _model($className, $instance = null) { $id = $this->_noNamespace($className); if (isset($this->_models->{$id})) { return; } $properties = array(); if (!$instance) { if (!class_exists($className)) return; $instance = new $className(); } $data = get_object_vars($instance); $reflectionClass = new \ReflectionClass($className); foreach ($data as $key => $value) { $propertyMetaData = null; try { $property = $reflectionClass->getProperty($key); if ($c = $property->getDocComment()) { $propertyMetaData = Util::nestedValue( CommentParser::parse($c), 'var' ); } } catch (\ReflectionException $e) { } if (is_null($propertyMetaData)) { $type = $this->getType($value, true); $description = ''; } else { $type = Util::nestedValue( $propertyMetaData, 'type' ) ? : $this->getType($value, true); $description = Util::nestedValue( $propertyMetaData, 'description' ) ? : ''; if (class_exists($type)) { $this->_model($type); $type = $this->_noNamespace($type); } } if (isset(static::$dataTypeAlias[$type])) { $type = static::$dataTypeAlias[$type]; } $properties[$key] = array( 'type' => $type, 'description' => $description ); if (Util::nestedValue( $propertyMetaData, CommentParser::$embeddedDataName, 'required' ) ) { $properties[$key]['required'] = true; } if ($type == 'Array') { $itemType = Util::nestedValue( $propertyMetaData, CommentParser::$embeddedDataName, 'type' ) ? : (count($value) ? $this->getType(end($value), true) : 'string'); if (class_exists($itemType)) { $this->_model($itemType); $itemType = $this->_noNamespace($itemType); } $properties[$key]['items'] = array( 'type' => $itemType, /*'description' => '' */ //TODO: add description ); } else if (preg_match('/^Array\[(.+)\]$/', $type, $matches)) { $itemType = $matches[1]; $properties[$key]['type'] = 'Array'; $properties[$key]['items']['type'] = $this->_noNamespace($itemType); if (class_exists($itemType)) { $this->_model($itemType); } } } if (!empty($properties)) { $model = new stdClass(); $model->id = $id; $model->properties = $properties; $this->_models->{$id} = $model; } } /** * Find the data type of the given value. * * * @param mixed $o given value for finding type * * @param bool $appendToModels if an object is found should we append to * our models list? * * @return string * * @access private */ public function getType($o, $appendToModels = false) { if (is_object($o)) { $oc = get_class($o); if ($appendToModels) { $this->_model($oc, $o); } return $this->_noNamespace($oc); } if (is_array($o)) { if (count($o)) { $child = end($o); if (Util::isObjectOrArray($child)) { $childType = $this->getType($child, $appendToModels); return "Array[$childType]"; } } return 'array'; } if (is_bool($o)) return 'boolean'; if (is_numeric($o)) return is_float($o) ? 'float' : 'int'; return 'string'; } /** * pre call for index() * * if cache is present, use cache */ public function _pre_index_json() { $userClass = Defaults::$userIdentifierClass; $this->cacheName = $userClass::getCacheIdentifier() . '_resources-v' . $this->restler->_requestedApiVersion; if ($this->restler->getProductionMode() && !$this->restler->refreshCache && $this->restler->cache->isCached($this->cacheName) ) { //by pass call, compose, postCall stages and directly send response $this->restler->composeHeaders(); die($this->restler->cache->get($this->cacheName)); } } /** * post call for index() * * create cache if in production mode * * @param $responseData * * @internal param string $data composed json output * * @return string */ public function _post_index_json($responseData) { if ($this->restler->getProductionMode()) { $this->restler->cache->set($this->cacheName, $responseData); } return $responseData; } /** * @access hybrid * @return \stdClass */ public function index() { if (!static::$accessControlFunction && Defaults::$accessControlFunction) static::$accessControlFunction = Defaults::$accessControlFunction; $version = $this->restler->getRequestedApiVersion(); $allRoutes = Util::nestedValue(Routes::toArray(), "v$version"); $r = $this->_resourceListing(); $map = array(); if (isset($allRoutes['*'])) { $this->_mapResources($allRoutes['*'], $map, $version); unset($allRoutes['*']); } $this->_mapResources($allRoutes, $map, $version); foreach ($map as $path => $description) { if (!Text::contains($path, '{')) { //add id $r->apis[] = array( 'path' => $path . $this->formatString, 'description' => $description ); } } if (Defaults::$useUrlBasedVersioning && static::$listHigherVersions) { $nextVersion = $version + 1; if ($nextVersion <= $this->restler->getApiVersion()) { list($status, $data) = $this->_loadResource("/v$nextVersion/resources.json"); if ($status == 200) { $r->apis = array_merge($r->apis, $data->apis); $r->apiVersion = $data->apiVersion; } } } return $r; } protected function _loadResource($url) { $ch = curl_init($this->restler->getBaseUrl() . $url . (empty($_GET) ? '' : '?' . http_build_query($_GET))); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_TIMEOUT, 15); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, array( 'Accept:application/json', )); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); $result = json_decode(curl_exec($ch)); $http_status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); return array($http_status, $result); } protected function _mapResources(array $allRoutes, array &$map, $version = 1) { foreach ($allRoutes as $fullPath => $routes) { $path = explode('/', $fullPath); $resource = isset($path[0]) ? $path[0] : ''; if ($resource == 'resources' || Text::endsWith($resource, 'index')) continue; foreach ($routes as $httpMethod => $route) { if (in_array($httpMethod, static::$excludedHttpMethods)) { continue; } if (!static::verifyAccess($route)) { continue; } foreach (static::$excludedPaths as $exclude) { if (empty($exclude)) { if ($fullPath == $exclude) continue 2; } elseif (Text::beginsWith($fullPath, $exclude)) { continue 2; } } $res = $resource ? ($version == 1 ? "/resources/$resource" : "/v$version/resources/$resource-v$version") : ($version == 1 ? "/resources/root" : "/v$version/resources/root-v$version"); if (empty($map[$res])) { $map[$res] = isset( $route['metadata']['classDescription']) ? $route['metadata']['classDescription'] : ''; } } } } /** * Maximum api version supported by the api class * @return int */ public static function __getMaximumSupportedVersion() { return Scope::get('Restler')->getApiVersion(); } /** * Verifies that the requesting user is allowed to view the docs for this API * * @param $route * * @return boolean True if the user should be able to view this API's docs */ protected function verifyAccess($route) { if ($route['accessLevel'] < 2) { return true; } if ( static::$hideProtected && !$this->_authenticated && $route['accessLevel'] > 1 ) { return false; } if ($this->_authenticated && static::$accessControlFunction && (!call_user_func( static::$accessControlFunction, $route['metadata'])) ) { return false; } return true; } }