diff --git a/README.md b/README.md index 1e2b969..770c147 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,105 @@ -yii-rest-api -============ +## Yii RESTful API + +This is extension for Yii Framework (http://www.yiiframework.com/), which can easy add RESTful API to existing web application. + +### INSTALLATION + +All of this code yo can see in *demo* folder. + +- Unpack *library* folder to *YOUR_EXTENSION_PATH/yii-rest-api* +- Update yours *config/main.php* + +Add new path of alias at the beginning + + YiiBase::setPathOfAlias('rest', realpath(__DIR__ . 'YOUR_EXTENSION_PATH/yii-rest-api/library/rest')); + +Add extension service to preload and components sections + + 'preload' => array('restService'), + + 'components' => array( + 'restService' => array( + 'class' => '\rest\Service', + 'enable' => isset($_REQUEST['_rest']), // for example + ), + ), + +Change routing settings + + 'urlManager'=>array( + 'urlFormat' => 'path', + 'showScriptName' => false, + 'baseUrl' => '', + 'rules' => array( + array('/index', 'pattern' => 'api/', 'verb' => 'GET'), + array('/create', 'pattern' => 'api/', 'verb' => 'POST'), + array('/view', 'pattern' => 'api//', 'verb' => 'GET'), + array('/update', 'pattern' => 'api//', 'verb' => 'PUT'), + array('/delete', 'pattern' => 'api//', 'verb' => 'DELETE'), + ) + ), + +- Update parent or specific Controller + +Add behavior + + public function behaviors() + { + return array( + 'restAPI' => array('class' => '\rest\controller\Behavior') + ); + } + +Overwrite render method (if need it) + + public function render($view, $data = null, $return = false, array $fields = null) + { + if (($behavior = $this->asa('restAPI')) && $behavior->getEnabled()) { + return $this->renderRest($view, $data, $return, $fields); + } else { + return parent::render($view, $data, $return); + } + } + +Overwrite redirect method (if need it) + + public function redirect($url, $terminate = true, $statusCode = 302) + { + if (($behavior = $this->asa('restAPI')) && $behavior->getEnabled()) { + $this->redirectRest($url, $terminate, $statusCode); + } else { + parent::redirect($url, $terminate, $statusCode); + } + } + +- Upate parent or specific ActiveRecord Model (or any other instance of CModel), if you need render rules. + +Add behavior + + public function behaviors() + { + return array( + 'renderModel' => array('class' => '\rest\model\Behavior') + ); + } + +Add rule + + public function rules() + { + return array( + array('field1,field2,field3', 'safe', 'on' => 'render'), + ); + } + +### REQUIREMENTS + +PHP >= 5.3.0 +Yii Framework >= 1.1.8 + +### LICENSE + +Copyright 2012 Pays I/O Ltd. + +Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php -Yii RESTful API \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e64a61e --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "paysio/yii-rest-api", + "description": "Yii Rest API Service", + "type": "yii-extension", + "keywords": [ + "Yii RESTful API", + "REST API" + ], + "homepage": "https://github.com/paysio/yii-rest-api", + "license": "MIT", + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "*", + "ext-curl": "*" + }, + "autoload": { + "psr-0": { + "rest\\": "library/", + "restTest\\": "tests/" + } + } +} \ No newline at end of file diff --git a/demo/config/main.php b/demo/config/main.php new file mode 100755 index 0000000..cc265c5 --- /dev/null +++ b/demo/config/main.php @@ -0,0 +1,43 @@ + dirname(__FILE__) . DIRECTORY_SEPARATOR . '..', + 'name' => 'My Web Application', + + 'preload' => array('restService'), + + 'import' => array( + 'application.models.*', + 'application.components.*', + ), + + 'components' => array( + 'restService' => array( + 'class' => '\rest\Service', + 'enable' => isset($_REQUEST['_rest']), + ), + + 'urlManager' => array( + 'urlFormat' => 'path', + 'showScriptName' => false, + 'baseUrl' => '', + 'rules' => array( + array('/index', 'pattern' => 'api/', 'verb' => 'GET'), + array('/create', 'pattern' => 'api/', 'verb' => 'POST'), + array('/view', 'pattern' => 'api//', 'verb' => 'GET'), + array('/update', 'pattern' => 'api//', 'verb' => 'PUT'), + array('/delete', 'pattern' => 'api//', 'verb' => 'DELETE'), + ) + ), + ), +); \ No newline at end of file diff --git a/demo/controllers/RestController.php b/demo/controllers/RestController.php new file mode 100644 index 0000000..fc2f6aa --- /dev/null +++ b/demo/controllers/RestController.php @@ -0,0 +1,147 @@ +restService->enable(); + } + + /** + * @return array + */ + public function behaviors() + { + return array( + 'restAPI' => array('class' => '\rest\controller\Behavior') + ); + } + + public function actionIndex() + { + $model = new RestMockModel(); + $data = array( + 'count' => 100, + 'data' => array($model, $model, $model) + ); + $this->render('empty', $data, false, array('count', 'data')); + } + + public function actionView() + { + $model = $this->loadModel(); + $this->render('empty', array('model' => $model), false, array('model')); + } + + public function actionCreate() + { + $model = new RestMockModel(); + + if ($this->isPost() && ($data = $_POST)) { + $model->attributes = $data; + if ($model->validate()) { + $this->redirect(array('view', 'id' => $model), true, 201); + } + } + $this->render('empty', array('model' => $model), false, array('model')); + } + + public function actionUpdate() + { + $model = $this->loadModel(); + $data = array( + 'version' => Yii::app()->request->getPut('version'), + 'name' => Yii::app()->request->getPut('name'), + ); + + if ($this->isPut() && $data) { + $model->attributes = $data; + if ($model->validate()) { + $this->redirect(array('view', 'id' => $model)); + } + } + $this->render('empty', array('model' => $model), false, array('model')); + } + + public function actionDelete() + { + if ($this->isDelete()) { + $model = $this->loadModel(); + $this->redirect(array('index', $model)); + } else { + throw new \CHttpException(400, Yii::t('app', 'Invalid delete request')); + } + } + + /** + * @return RestMockModel + * @throws CHttpException + */ + public function loadModel() + { + $id = isset($_GET['id']) ? $_GET['id'] : null; + $object = new RestMockModel(); + if ($id != $object->id) { + throw new CHttpException(404, Yii::t('app', 'Object not found')); + } + return $object; + } + + + + /** + * Renders a view with a layout. + * + * @param string $view name of the view to be rendered. See {@link getViewFile} for details + * about how the view script is resolved. + * @param array $data data to be extracted into PHP variables and made available to the view script + * @param boolean $return whether the rendering result should be returned instead of being displayed to end users. + * @param array $fields allowed fields to REST render + * @return string the rendering result. Null if the rendering result is not required. + * @see renderPartial + * @see getLayoutFile + */ + public function render($view, $data = null, $return = false, array $fields = array()) + { + if (($behavior = $this->asa('restAPI')) && $behavior->getEnabled()) { + return $this->renderRest($view, $data, $return, $fields); + } else { + return parent::render($view, $data, $return); + } + } + + /** + * Redirects the browser to the specified URL or route (controller/action). + * @param mixed $url the URL to be redirected to. If the parameter is an array, + * the first element must be a route to a controller action and the rest + * are GET parameters in name-value pairs. + * @param boolean|integer $terminate whether to terminate OR REST response status code !!! + * @param integer $statusCode the HTTP status code. Defaults to 302. See {@link http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html} + * for details about HTTP status code. + */ + public function redirect($url, $terminate = true, $statusCode = 302) + { + if (($behavior = $this->asa('restAPI')) && $behavior->getEnabled()) { + $this->redirectRest($url, $terminate, $statusCode); + } else { + parent::redirect($url, $terminate, $statusCode); + } + } +} \ No newline at end of file diff --git a/demo/models/RestMockModel.php b/demo/models/RestMockModel.php new file mode 100644 index 0000000..82c662c --- /dev/null +++ b/demo/models/RestMockModel.php @@ -0,0 +1,59 @@ +attachBehaviors($this->behaviors()); + } + + /** + * @return array + */ + public function attributeNames() + { + return array('id', 'version', 'name', 'hidden'); + } + + /** + * @return array + */ + public function rules() + { + return array( + array('version', 'numerical'), + array('name', 'length', 'max' => 244), + + array('id,version,name', 'safe', 'on' => 'render'), + ); + } + + /** + * @return array + */ + public function behaviors() + { + return array( + 'renderModel' => array('class' => '\rest\model\Behavior') + ); + } +} \ No newline at end of file diff --git a/library/rest/Service.php b/library/rest/Service.php new file mode 100644 index 0000000..8b309f6 --- /dev/null +++ b/library/rest/Service.php @@ -0,0 +1,396 @@ + '\rest\service\auth\adapters\Basic', + ); + + /** + * @var array + */ + public $rendererAdapterConfig = array( + 'class' => '\rest\service\renderer\adapters\Json', + ); + + /** + * @var string + */ + public $controllerBehaviorName = 'restAPI'; + + /** + * @var string + */ + public $modelBehaviorName = 'renderModel'; + + /** + * @var \rest\service\auth\AdapterInterface + */ + protected $_authAdapter; + /** + * @var \rest\service\renderer\AdapterInterface + */ + protected $_rendererAdapter; + + /** + * Init service + */ + public function init() + { + if (!$this->isEnabled()) { + return; + } + $this->disable(); + $app = \Yii::app(); + + $app->request->enableCsrfValidation = false; + $app->detachEventHandler('onBeginRequest',array($app->request, 'validateCsrfToken')); + + $app->attachEventHandler('onEndRequest', array($this, 'onEndRequest')); + $app->attachEventHandler('onBeginRequest', array($this, 'onBeginRequest')); + } + + /** + * @param \CEvent $event + */ + public function onBeginRequest(\CEvent $event) + { + $app = \Yii::app(); + + $app->attachEventHandler('onException', array($this, 'onException')); + $app->attachEventHandler('onError', array($this, 'onError')); + + $this->enable(); + $this->getAuthAdapter()->authenticate(); + } + + /** + * @param \CEvent $event + */ + public function onEndRequest(\CEvent $event) + { + + } + + /** + * Raised when an uncaught PHP exception occurs. + * @param \CExceptionEvent $event event parameter + */ + public function onException(\CExceptionEvent $event) + { + $event->handled = true; + + if ($event->exception instanceof \CHttpException) { + $statusCode = $event->exception->statusCode; + $message = $event->exception->getMessage(); + $type = self::ERR_TYPE_REQUEST; + } else { + $statusCode = 500; + $message = $event->exception->getMessage(); + $type = self::ERR_TYPE_API; + } + + $this->_setErrorHandlerError(array( + 'code' => ($event->exception instanceof \CHttpException) ? $event->exception->statusCode : 500, + 'type' => get_class($event->exception), + 'errorCode' => $event->exception->getCode(), + 'message' => $event->exception->getMessage(), + 'file' => $event->exception->getFile(), + 'line' => $event->exception->getLine(), + 'trace' => $event->exception->getTraceAsString(), + 'traces' => $event->exception->getTrace(), + )); + + $this->sendError($type, $message, array(), $statusCode); + } + + /** + * Set CErrorHandler::_error property + * @param array $error + */ + protected function _setErrorHandlerError(array $error) + { + $refObject = new \ReflectionObject(\Yii::app()->errorHandler); + $refProperty = $refObject->getProperty('_error'); + $refProperty->setAccessible(true); + $refProperty->setValue(\Yii::app()->errorHandler, $error); + } + + /** + * Raised when a PHP execution error occurs. + * @param \CErrorEvent $event event parameter + */ + public function onError(\CErrorEvent $event) + { + $event->handled = true; + + $this->_setErrorHandlerError(array( + 'code' => 500, + 'type' => $event->code, + 'message' => $event->message, + 'file' => $event->file, + 'line' => $event->line, + 'trace' => '', + 'traces' => array(), + )); + + $this->sendError(self::ERR_TYPE_API, $event->message); + } + + /** + * @param $type + * @param $message + * @param array $data + * @param int $statusCode + */ + public function sendError($type, $message, array $data = array(), $statusCode = 500) + { + ob_clean(); + + $data['type'] = $type; + $data['message'] = $message; + + $this->_send(array('error' => $data), $statusCode); + } + + /** + * @param $data + * @param array $filterFields + * @param int $statusCode + */ + public function sendData($data, array $filterFields = null, $statusCode = 200) + { + if ($filterFields !== null) { + $filteredData = array(); + foreach ($filterFields as $field) { + if (!array_key_exists($field, $data)) { + continue; + } + $filteredData[$field] = $this->_filterData($data[$field]); + } + $data = $filteredData; + } else { + $data = $this->_filterData($data); + } + + $this->_send($data, $statusCode); + } + + /** + * @param $data + * @return mixed + */ + protected function _filterData($data) + { + if ($data instanceof \CModel && $data->hasErrors()) { + $this->_setErrorHandlerError(array( + 'code' => 400, + 'type' => self::ERR_TYPE_PARAM, + 'message' => _('Invalid request params'), + 'file' => __FILE__, + 'line' => __LINE__, + 'trace' => '', + 'traces' => array(), + )); + + $this->sendError(self::ERR_TYPE_PARAM, \Yii::t('ext', 'Invalid data parameters'), array( + 'params' => $this->_generateModelErrorFields($data) + ), 400); + } + + if ($data instanceof \CComponent && $data->asa($this->modelBehaviorName)) { + $data = $data->getRenderAttributes(false); + } + if (is_array($data) || ($data instanceof \Traversable && !($data instanceof \CModel))) { + $filteredData = array(); + foreach ($data as $key => $row) { + $filteredData[$key] = $this->_filterData($row); + } + $data = $filteredData; + } elseif ($data instanceof \IDataProvider) { + $data = $this->_filterData($data->getData()); + } + + return $data; + } + + /** + * @param \CModel $model + * @return array + */ + protected function _generateModelErrorFields(\CModel $model) + { + $validators = \CValidator::$builtInValidators; + + $errors = $model->getErrors(); + $errorFields = array_keys($errors); + $errorHandled = array(); + + $i = 0; + $result = array(); + foreach ($model->getValidators() as $validator) { + if (isset($hasError) && $validator->skipOnError || !array_intersect($validator->attributes, $errorFields)) { + continue; + } + $model->clearErrors(); + $validator->validate($model); + if ($model->hasErrors()) { + $hasError = true; + $code = array_search(get_class($validator), $validators); + if ($validator instanceof \CInlineValidator) { + $code = $validator->method; + } + + foreach ($validator->attributes as $attribute) { + if ($model->hasErrors($attribute)) { + $result[$i]['code'] = $code; + $result[$i]['message'] = $model->getError($attribute); + $result[$i]['name'] = $attribute; + + $errorHandled[] = $attribute; + $i++; + } + } + } + } + + foreach (array_diff($errorFields, $errorHandled) as $attribute) { + $result[$i]['code'] = 'unknown'; + $result[$i]['message'] = implode(',', $errors[$attribute]); + $result[$i]['name'] = $attribute; + } + + return $result; + } + + /** + * @param $data + * @param int $statusCode + */ + protected function _send($data, $statusCode = 200) + { + if (!$data) { + $data = new \stdClass(); + } + $reasonPhrase = self::getStatusPhrase($statusCode); + + header($_SERVER['SERVER_PROTOCOL'] . " {$statusCode} {$reasonPhrase}"); + + $this->getRendererAdapter()->render($data); + + \Yii::app()->end(); + } + + /** + * @static + * @param $statusCode + * @return string + */ + public static function getStatusPhrase($statusCode) + { + switch ($statusCode) { + case 200: + $reasonPhrase = 'OK'; + break; + case 201: + $reasonPhrase = 'Created'; + break; + case 400: + $reasonPhrase = 'Bad Request'; + break; + case 401: + $reasonPhrase = 'Unauthorized'; + break; + case 403: + $reasonPhrase = 'Forbidden'; + break; + case 404: + $reasonPhrase = 'Not Found'; + break; + case 500: + $reasonPhrase = 'Internal Server Error'; + break; + default: + $reasonPhrase = '...'; + } + return $reasonPhrase; + } + + /** + * @return \rest\service\auth\AdapterInterface + */ + public function getAuthAdapter() + { + if ($this->_authAdapter === null) { + $this->_authAdapter = \Yii::createComponent($this->authAdapterConfig); + } + return $this->_authAdapter; + } + + /** + * @return \rest\service\renderer\AdapterInterface + */ + public function getRendererAdapter() + { + if ($this->_rendererAdapter === null) { + $this->_rendererAdapter = \Yii::createComponent($this->rendererAdapterConfig); + } + return $this->_rendererAdapter; + } + + /** + * @return Service + */ + public function enable() + { + $this->_enabled = true; + return $this; + } + + /** + * @return Service + */ + public function disable() + { + $this->_enabled = false; + return $this; + } + + /** + * @param $value + */ + public function setEnable($value) + { + $this->_enabled = $value; + } + + /** + * @return bool + */ + public function isEnabled() + { + return $this->_enabled; + } +} \ No newline at end of file diff --git a/library/rest/controller/Behavior.php b/library/rest/controller/Behavior.php new file mode 100644 index 0000000..a6037e4 --- /dev/null +++ b/library/rest/controller/Behavior.php @@ -0,0 +1,182 @@ +request->isPostRequest; + } + + /** + * Check PUT request + * @return bool + */ + public function isPut() + { + $request = \Yii::app()->request; + return $this->isRestService() ? $request->isPutRequest : $request->isPostRequest; + } + + /** + * Check DELETE request + * @return bool + */ + public function isDelete() + { + $request = \Yii::app()->request; + return $this->isRestService() ? $request->isDeleteRequest : $request->isPostRequest; + } + + /** + * Renders a view with a layout. + * + * @param string $view name of the view to be rendered. See {@link getViewFile} for details + * about how the view script is resolved. + * @param array $data data to be extracted into PHP variables and made available to the view script + * @param boolean $return whether the rendering result should be returned instead of being displayed to end users. + * @param array $fields allowed fields to REST render + * @return string the rendering result. Null if the rendering result is not required. + * @see renderPartial + * @see getLayoutFile + */ + public function renderRest($view, $data = null, $return = false, array $fields = null) + { + if ($this->hasEventHandler('onBeforeRender')) { + $this->onBeforeRender(new \CEvent($this, array('view' => &$view, 'data' => &$data, 'return' => &$return, 'fields' => &$fields))); + } + + if ($this->isRestService() && !$return) { + $this->getRestService()->sendData($data, $fields); + } + + $this->getOwner()->disableBehavior($this->behaviorName); + $result = $this->getOwner()->render($view, $data, $return); + $this->getOwner()->enableBehavior($this->behaviorName); + + return $result; + } + + /** + * @param \CEvent $event + */ + public function onBeforeRender(\CEvent $event) + { + $this->raiseEvent('onBeforeRender', $event); + } + + /** + * Redirects the browser to the specified URL or route (controller/action). + * @param mixed $url the URL to be redirected to. If the parameter is an array, + * the first element must be a route to a controller action and the rest + * are GET parameters in name-value pairs. + * @param boolean|integer $terminate whether to terminate OR REST response status code !!! + * @param integer $statusCode the HTTP status code. Defaults to 302. See {@link http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html} + * for details about HTTP status code. + */ + public function redirectRest($url, $terminate = true, $statusCode = 302) + { + if ($this->hasEventHandler('onBeforeRedirect')) { + $this->onBeforeRedirect(new \CEvent($this, array('url' => &$url, 'terminate' => &$terminate, 'statusCode' => &$statusCode))); + } + + $model = $this->replaceModelIdInUrl($url); + + $this->getOwner()->disableBehavior($this->behaviorName); + if ($this->isRestService()) { + if ($statusCode == 201) { + $this->getOwner()->redirect($url, false, 201); + } else { + $statusCode = 200; + } + $this->getRestService()->sendData($model, null, $statusCode); + } + + $this->getOwner()->redirect($url, $terminate); + $this->getOwner()->enableBehavior($this->behaviorName); + } + + /** + * @param \CEvent $event + */ + public function onBeforeRedirect(\CEvent $event) + { + $this->raiseEvent('onBeforeRedirect', $event); + } + + /** + * @param $url + * @return null|\CModel + */ + public function replaceModelIdInUrl(&$url) + { + $model = null; + if (is_array($url) && ($params = array_splice($url, 1))) { + $route = isset($url[0]) ? $url[0] : ''; + foreach ($params as $id => $param) { + if ($param instanceof \CModel) { + if (isset($param->$id)) { + $params[$id] = $param->$id; + } else { + unset($params[$id]); + } + $model = $param; + } + } + if (strpos($route, 'http') === false) { + $url = array_merge(array($route), $params); + } else { + $url = $route . ($params ? '?' . http_build_query($params) : ''); + } + } + return $model; + } + + /** + * Is REST service enabled + * @return bool + */ + public function isRestService() + { + return $this->getRestService()->isEnabled(); + } + + /** + * @return \rest\Service + */ + public function getRestService() + { + return \Yii::app()->{$this->serviceName}; + } + + /** + * @return \CController + */ + public function getOwner() + { + return parent::getOwner(); + } +} \ No newline at end of file diff --git a/library/rest/model/Behavior.php b/library/rest/model/Behavior.php new file mode 100644 index 0000000..4b82f21 --- /dev/null +++ b/library/rest/model/Behavior.php @@ -0,0 +1,83 @@ +getOwner()->rules() as $rule) { + if (!isset($rule['on']) || $rule['on'] != $this->scenarioName) { + continue; + } + $attr = explode(',', $rule[0]); + $attributes = array_merge($attributes, array_map('trim', $attr)); + } + return array_unique($attributes); + } + + /** + * @param bool $recursive + * @return array + */ + public function getRenderAttributes($recursive = true) + { + $model = $this->getOwner(); + $attrs = array('object' => $this->getObjectId($model)); + foreach ($this->getAttributeNames() as $name) { + $attr = $model->$name; + if ($recursive) { + if ($attr instanceof \CComponent && $attr->asa($this->behaviorName)) { + $attr = $attr->getRenderAttributes($recursive); + } elseif (is_array($attr) || $attr instanceof \Traversable) { + $renderedAttr = array(); + foreach ($attr as $key => $row) { + if ($row instanceof \CComponent && $row->asa($this->behaviorName)) { + $renderedAttr[$key] = $row->getRenderAttributes($recursive); + } else { + $renderedAttr[$key] = $row; + } + } + $attr = $renderedAttr; + } + } + $attrs[$name] = $attr; + } + return $attrs; + } + + public function getObjectId($model) + { + return strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', get_class($model))); + } + + /** + * @return \CModel + */ + public function getOwner() + { + return parent::getOwner(); + } +} \ No newline at end of file diff --git a/library/rest/service/auth/AdapterInterface.php b/library/rest/service/auth/AdapterInterface.php new file mode 100644 index 0000000..856e00f --- /dev/null +++ b/library/rest/service/auth/AdapterInterface.php @@ -0,0 +1,20 @@ +realm . '"'); + + if (!isset($_SERVER['PHP_AUTH_USER'])) { + throw new \CHttpException(401, \Yii::t('ext', 'Undefined auth user')); + } + + $user = $_SERVER['PHP_AUTH_USER']; + $password = $_SERVER['PHP_AUTH_PW']; + + $identityClass = \Yii::import($this->identityClass); + $identity = new $identityClass($user, $password); + if (!$identity->authenticate()) { + throw new \CHttpException(401, $identity->errorMessage); + } + + \Yii::app()->user->login($identity); + } +} \ No newline at end of file diff --git a/library/rest/service/renderer/AdapterInterface.php b/library/rest/service/renderer/AdapterInterface.php new file mode 100644 index 0000000..4f3ce4f --- /dev/null +++ b/library/rest/service/renderer/AdapterInterface.php @@ -0,0 +1,22 @@ +'); + $this->arrayToXml($data, $xml); + + $result = $xml->asXML(); + + if ($return) { + return $result; + } + + header('Content-type: application/xhtml+xml'); + echo $result; + } + + public function arrayToXml($data, &$xml) + { + foreach ($data as $key => $value) { + if (is_array($value)) { + if (isset($value['object'])) { + $subnode = $xml->addChild((string)$value['object']); + unset($value['object']); + } else { + $subnode = $xml->addChild(is_numeric($key) ? "item_$key" : (string)$key); + } + + $this->arrayToXml($value, $subnode); + } else { + $xml->addChild("$key","$value"); + } + } + } +} \ No newline at end of file diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php new file mode 100644 index 0000000..86c7c4e --- /dev/null +++ b/tests/Bootstrap.php @@ -0,0 +1,20 @@ + + + + ./restTest + + + + + + + \ No newline at end of file diff --git a/tests/restTest/AbstractTest.php b/tests/restTest/AbstractTest.php new file mode 100644 index 0000000..a3ba7c9 --- /dev/null +++ b/tests/restTest/AbstractTest.php @@ -0,0 +1,125 @@ + 0) { + $url .= '?' . $this->_encode($params); + } + return $this->_request($url, $options); + } + + /** + * @param $url + * @param array $params + * @param array $options + * @return array + */ + public function post($url, array $params, array $options = null) + { + $options[CURLOPT_POST] = 1; + $options[CURLOPT_POSTFIELDS] = $params; + return $this->_request($url, $options); + } + + /** + * @param $url + * @param array $params + * @param array $options + * @return array + */ + public function put($url, array $params, array $options = null) + { + $options[CURLOPT_CUSTOMREQUEST] = 'PUT'; + $options[CURLOPT_POSTFIELDS] = $params; + return $this->_request($url, $options); + } + + /** + * @param $url + * @param array $params + * @param array $options + * @return array + */ + public function delete($url, array $params = null, array $options = null) + { + $options[CURLOPT_CUSTOMREQUEST] = 'DELETE'; + if (count($params) > 0) { + $options[CURLOPT_POSTFIELDS] = $params; + } + return $this->_request($url, $options); + } + + /** + * @param $url + * @param array $options + * @param array $headers + * @return array + * @throws \CException + */ + protected function _request($url, array $options, array $headers = array()) + { + $ch = curl_init(); + + $defaultOptions = array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => 1, + ); + + if (isset($options[CURLOPT_POSTFIELDS])) { + $options[CURLOPT_POSTFIELDS] = $this->_encode($options[CURLOPT_POSTFIELDS]); + } + + $options = $defaultOptions + $options; + + $options[CURLOPT_URL] = rtrim(TEST_BASE_URL, '/') . '/' . utf8_encode(trim($url, '/')); + + if ($headers !== null) { + $options[CURLOPT_HTTPHEADER] = $headers; + } + + curl_setopt_array($ch, $options); + + $response = curl_exec($ch); + if ($response === false) { + throw new \CException(curl_error($ch), curl_errno($ch)); + } + + $info = curl_getinfo($ch); + + $header = substr($response, 0, $info['header_size']); + $body = substr($response, $info['header_size']); + + preg_match("/Location: (.*?)\n/", $header, $matches); + $location = isset($matches[1]) ? $matches[1] : null; + + return array('body' => $body, 'code' => $info['http_code'], 'location' => $location); + } + + /** + * @param array $params + * @return string + */ + protected function _encode(array $params) + { + return http_build_query($params, null, '&'); + } +} \ No newline at end of file diff --git a/tests/restTest/RestControllerTest.php b/tests/restTest/RestControllerTest.php new file mode 100644 index 0000000..a1b7fdb --- /dev/null +++ b/tests/restTest/RestControllerTest.php @@ -0,0 +1,84 @@ +post('/api/rest', array('version' => 0.2)); + $model = json_decode($result['body']); + + $this->assertEquals($model->id, 'TEST_ID'); + $this->assertEquals($model->version, 0.2); + + $this->assertEquals($result['code'], 201); + + $this->assertContains('api/rest/TEST_ID', $result['location']); + } + + public function testView() + { + $result = $this->get('/api/rest/TEST_ID'); + $model = json_decode($result['body']); + $model = $model->model; + + $this->assertEquals($model->id, 'TEST_ID'); + $this->assertEquals($model->version, 0.1); + + $this->assertEquals($result['code'], 200); + } + + public function testIndex() + { + $result = $this->get('/api/rest'); + $data = json_decode($result['body']); + + $this->assertEquals($data->count, 100); + $this->assertCount(3, $data->data); + $this->assertEquals($data->data[0]->id, 'TEST_ID'); + + $this->assertEquals($result['code'], 200); + } + + public function testUpdate() + { + $result = $this->put('/api/rest/TEST_ID', array('version' => '0.3')); + $model = json_decode($result['body']); + + $this->assertEquals($model->id, 'TEST_ID'); + $this->assertEquals($model->version, 0.3); + + $this->assertEquals($result['code'], 200); + } + + public function testError() + { + $result = $this->put('/api/rest/TEST_ID', array('version' => 'wrong_version')); + $model = json_decode($result['body']); + + $this->assertEquals($model->error->type, 'invalid_param_error'); + $this->assertCount(1, $model->error->params); + $this->assertEquals($model->error->params[0]->name, 'version'); + + $this->assertEquals($result['code'], 400); + } + + public function testDelete() + { + $result = $this->delete('/api/rest/TEST_ID'); + $model = json_decode($result['body']); + + $this->assertEquals($model->id, 'TEST_ID'); + + $this->assertEquals($result['code'], 200); + } +} \ No newline at end of file