├── .gitignore ├── Config ├── Migration │ ├── 001_initial_migration.php │ └── map.php └── Schema │ ├── rest_logs.sql │ └── schema.php ├── Controller └── Component │ └── RestComponent.php ├── Lib └── BluntXml.php ├── Model ├── RestAppModel.php └── RestLog.php ├── README.md ├── Test └── Case │ ├── Component │ └── RestComponentTest.php │ └── Helper │ └── RestXmlHelperTest.php └── View ├── CsvEncodeView.php ├── JsonEncodeView.php └── XmlEncodeView.php /.gitignore: -------------------------------------------------------------------------------- 1 | .gitup.dat 2 | *.swp 3 | -------------------------------------------------------------------------------- /Config/Migration/001_initial_migration.php: -------------------------------------------------------------------------------- 1 | array( 20 | 'create_table' => array( 21 | 'rest_logs' => array( 22 | 'id' => array('type' => 'integer', 'null' => false, 'default' => NULL, 'length' => 20, 'key' => 'primary'), 23 | 'class' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 24 | 'username' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 25 | 'controller' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 26 | 'action' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 27 | 'model_id' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 28 | 'ip' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 16, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 29 | 'requested' => array('type' => 'datetime', 'null' => false, 'default' => NULL, 'key' => 'index'), 30 | 'apikey' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 64, 'key' => 'index', 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 31 | 'httpcode' => array('type' => 'integer', 'null' => false, 'default' => NULL, 'length' => 3), 32 | 'error' => array('type' => 'string', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 33 | 'ratelimited' => array('type' => 'boolean', 'null' => false, 'default' => NULL), 34 | 'data_in' => array('type' => 'text', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 35 | 'meta' => array('type' => 'text', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 36 | 'data_out' => array('type' => 'text', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 37 | 'responded' => array('type' => 'datetime', 'null' => false, 'default' => NULL), 38 | 'created' => array('type' => 'datetime', 'null' => false, 'default' => NULL), 39 | 'modified' => array('type' => 'datetime', 'null' => false, 'default' => NULL), 40 | 'indexes' => array( 41 | 'PRIMARY' => array('column' => 'id', 'unique' => 1), 42 | 'logged_in_ratelimit' => array('column' => array('apikey', 'requested'), 'unique' => 0), 43 | 'logged_out_ratelimit' => array('column' => array('requested', 'ip'), 'unique' => 0), 44 | ), 45 | 'tableParameters' => array('charset' => 'utf8', 'collate' => 'utf8_general_ci', 'engine' => 'InnoDB'), 46 | ), 47 | ), 48 | ), 49 | 'down' => array( 50 | 'drop_table' => array( 51 | 'rest_logs' 52 | ), 53 | ), 54 | ); 55 | 56 | /** 57 | * Before migration callback 58 | * 59 | * @param string $direction, up or down direction of migration process 60 | * @return boolean Should process continue 61 | * @access public 62 | */ 63 | public function before($direction) { 64 | return true; 65 | } 66 | 67 | /** 68 | * After migration callback 69 | * 70 | * @param string $direction, up or down direction of migration process 71 | * @return boolean Should process continue 72 | * @access public 73 | */ 74 | public function after($direction) { 75 | return true; 76 | } 77 | } 78 | ?> -------------------------------------------------------------------------------- /Config/Migration/map.php: -------------------------------------------------------------------------------- 1 | array( 4 | '001_initial_migration' => 'M4e01559381044995b3ed4801cbdd56cb'), 5 | ); 6 | ?> -------------------------------------------------------------------------------- /Config/Schema/rest_logs.sql: -------------------------------------------------------------------------------- 1 | -- DROP TABLE IF EXISTS `rest_logs`; 2 | CREATE TABLE `rest_logs` ( 3 | `id` bigint(20) UNSIGNED NOT NULL auto_increment, 4 | `class` varchar(40) character set utf8 collate utf8_unicode_ci NOT NULL, 5 | `username` varchar(40) character set utf8 collate utf8_unicode_ci NOT NULL, 6 | `controller` varchar(40) character set utf8 collate utf8_unicode_ci NOT NULL, 7 | `action` varchar(40) character set utf8 collate utf8_unicode_ci NOT NULL, 8 | `model_id` varchar(40) character set utf8 collate utf8_unicode_ci NOT NULL, 9 | `ip` varchar(16) character set utf8 collate utf8_unicode_ci NOT NULL, 10 | `requested` datetime NOT NULL, 11 | `apikey` varchar(64) character set utf8 collate utf8_unicode_ci NOT NULL, 12 | `httpcode` smallint(3) UNSIGNED NOT NULL, 13 | `error` varchar(255) character set utf8 collate utf8_unicode_ci NOT NULL, 14 | `ratelimited` tinyint(1) UNSIGNED NOT NULL, 15 | `data_in` text character set utf8 collate utf8_unicode_ci NOT NULL, 16 | `meta` text character set utf8 collate utf8_unicode_ci NOT NULL, 17 | `data_out` text character set utf8 collate utf8_unicode_ci NOT NULL, 18 | `responded` datetime NOT NULL, 19 | `created` datetime NOT NULL, 20 | `modified` datetime NOT NULL, 21 | PRIMARY KEY (`id`) 22 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -------------------------------------------------------------------------------- /Config/Schema/schema.php: -------------------------------------------------------------------------------- 1 | array('type' => 'integer', 'null' => false, 'default' => NULL, 'length' => 20, 'key' => 'primary'), 15 | 'class' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 16 | 'username' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 17 | 'controller' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 18 | 'action' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 19 | 'model_id' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 40, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 20 | 'ip' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 16, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 21 | 'requested' => array('type' => 'datetime', 'null' => false, 'default' => NULL, 'key' => 'index'), 22 | 'apikey' => array('type' => 'string', 'null' => false, 'default' => NULL, 'length' => 64, 'key' => 'index', 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 23 | 'httpcode' => array('type' => 'integer', 'null' => false, 'default' => NULL, 'length' => 3), 24 | 'error' => array('type' => 'string', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 25 | 'ratelimited' => array('type' => 'boolean', 'null' => false, 'default' => NULL), 26 | 'data_in' => array('type' => 'text', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 27 | 'meta' => array('type' => 'text', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 28 | 'data_out' => array('type' => 'text', 'null' => false, 'default' => NULL, 'collate' => 'utf8_unicode_ci', 'charset' => 'utf8'), 29 | 'responded' => array('type' => 'datetime', 'null' => false, 'default' => NULL), 30 | 'created' => array('type' => 'datetime', 'null' => false, 'default' => NULL), 31 | 'modified' => array('type' => 'datetime', 'null' => false, 'default' => NULL), 32 | 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1), 'logged_in_ratelimit' => array('column' => array('apikey', 'requested'), 'unique' => 0), 'logged_out_ratelimit' => array('column' => array('requested', 'ip'), 'unique' => 0)), 33 | 'tableParameters' => array('charset' => 'utf8', 'collate' => 'utf8_general_ci', 'engine' => 'InnoDB') 34 | ); 35 | } 36 | ?> -------------------------------------------------------------------------------- /Controller/Component/RestComponent.php: -------------------------------------------------------------------------------- 1 | 'OK', 5 | 400 => 'Bad Request', 6 | 401 => 'Unauthorized', 7 | 402 => 'Payment Required', 8 | 403 => 'Forbidden', 9 | 404 => 'Not Found', 10 | 405 => 'Method Not Allowed', 11 | 406 => 'Not Acceptable', 12 | 407 => 'Proxy Authentication Required', 13 | 408 => 'Request Time-out', 14 | 500 => 'Internal Server Error', 15 | 501 => 'Not Implemented', 16 | 502 => 'Bad Gateway', 17 | 503 => 'Service Unavailable', 18 | 504 => 'Gateway Time-out', 19 | ); 20 | 21 | public $Controller; 22 | public $postData; 23 | 24 | protected $_RestLog; 25 | protected $_View; 26 | protected $_logData = array(); 27 | protected $_feedback = array(); 28 | protected $_credentials = array(); 29 | protected $_aborting = false; 30 | 31 | public $settings = array( 32 | // Component options 33 | 'callbacks' => array( 34 | 'cbRestlogBeforeSave' => 'restlogBeforeSave', 35 | 'cbRestlogAfterSave' => 'restlogAfterSave', 36 | 'cbRestlogBeforeFind' => 'restlogBeforeFind', 37 | 'cbRestlogAfterFind' => 'restlogAfterFind', 38 | 'cbRestlogFilter' => 'restlogFilter', 39 | 'cbRestRatelimitMax' => 'restRatelimitMax', 40 | ), 41 | 'extensions' => array('xml', 'json'), 42 | 'viewsFromPlugin' => true, 43 | 'skipControllers' => array( // Don't show these as actual rest controllers even though they have the component attached 44 | 'App', 45 | 'Defaults', 46 | ), 47 | 'auth' => array( 48 | 'requireSecure' => false, 49 | 'keyword' => 'TRUEREST', 50 | 'fields' => array( 51 | 'class' => 'class', 52 | 'apikey' => 'apikey', 53 | 'username' => 'username', 54 | ), 55 | ), 56 | 'exposeVars' => array( 57 | '*' => array( 58 | 'method' => 'get|post|put|delete', 59 | 'id' => 'true|false', 60 | ), 61 | 'index' => array( 62 | 'scopeVar' => 'scope|rack_name|any_other_varname_to_specify_scope', 63 | ), 64 | ), 65 | 'defaultVars' => array( 66 | 'index' => array( 67 | 'scopeVar' => 'scope', 68 | 'method' => 'get', 69 | 'id' => false, 70 | ), 71 | 'view' => array( 72 | 'scopeVar' => 'scope', 73 | 'method' => 'get', 74 | 'id' => true, 75 | ), 76 | 'edit' => array( 77 | 'scopeVar' => 'scope', 78 | 'method' => 'put', 79 | 'id' => true, 80 | ), 81 | 'add' => array( 82 | 'scopeVar' => 'scope', 83 | 'method' => 'put', 84 | 'id' => false, 85 | ), 86 | 'delete' => array( 87 | 'scopeVar' => 'scope', 88 | 'method' => 'delete', 89 | 'id' => true, 90 | ), 91 | ), 92 | 'log' => array( 93 | 'model' => 'Rest.RestLog', 94 | 'pretty' => true, 95 | // Optionally, choose to store some log fields on disk, instead of in the database 96 | 'fields' => array( 97 | 'data_in' => '{LOGS}rest-{date_Y}_{date_m}/{username}_{id}_1_{field}.log', 98 | 'meta' => '{LOGS}rest-{date_Y}_{date_m}/{username}_{id}_2_{field}.log', 99 | 'data_out' => '{LOGS}rest-{date_Y}_{date_m}/{username}_{id}_3_{field}.log', 100 | ), 101 | ), 102 | 'meta' => array( 103 | 'enable' => true, 104 | 'requestKeys' => array( 105 | 'HTTP_HOST', 106 | 'HTTP_USER_AGENT', 107 | 'REMOTE_ADDR', 108 | 'REQUEST_METHOD', 109 | 'REQUEST_TIME', 110 | 'REQUEST_URI', 111 | 'SERVER_ADDR', 112 | 'SERVER_PROTOCOL', 113 | ), 114 | ), 115 | 'ratelimit' => array( 116 | 'enable' => true, 117 | 'default' => 'Customer', 118 | 'classlimits' => array( 119 | 'Employee' => array('-1 hour', 1000), 120 | 'Customer' => array('-1 hour', 100), 121 | ), 122 | 'identfield' => 'apikey', 123 | 'ip_limit' => array('-1 hour', 60), // For those not logged in 124 | ), 125 | 'version' => '0.3', 126 | 'actions' => array( 127 | 'view' => array( 128 | 'extract' => array(), 129 | ), 130 | ), 131 | 'debug' => 0, 132 | 'onlyActiveWithAuth' => false, 133 | 'catchredir' => false, 134 | ); 135 | 136 | /** 137 | * Should the rest plugin be active? 138 | * 139 | * @var string 140 | */ 141 | public $isActive = null; 142 | 143 | public function __construct(ComponentCollection $collection, $settings = array()) { 144 | $_settings = $this->settings; 145 | if (is_array($config = Configure::read('Rest.settings'))) { 146 | $_settings = Set::merge($_settings, $config); 147 | } 148 | $settings = Set::merge($_settings, $settings); 149 | 150 | parent::__construct($collection, $settings); 151 | } 152 | 153 | public function initialize (Controller $Controller) { 154 | $this->Controller = $Controller; 155 | 156 | if (!$this->isActive()) { 157 | return; 158 | } 159 | 160 | // Control Debug 161 | $this->settings['debug'] = (int)$this->settings['debug']; 162 | Configure::write('debug', $this->settings['debug']); 163 | 164 | // Set credentials 165 | $this->credentials(true); 166 | 167 | // Prepare log 168 | $this->log(array( 169 | 'controller' => $this->Controller->name, 170 | 'action' => $this->Controller->action, 171 | 'model_id' => @$this->Controller->passedArgs[0] 172 | ? @$this->Controller->passedArgs[0] 173 | : 0, 174 | 'ratelimited' => 0, 175 | 'requested' => date('Y-m-d H:i:s'), 176 | 'ip' => $_SERVER['REMOTE_ADDR'], 177 | 'httpcode' => 200, 178 | )); 179 | 180 | // Validate & Modify Post 181 | $this->postData = $this->_modelizePost($this->Controller->request->data); 182 | if ($this->postData === false) { 183 | return $this->abort('Invalid post data'); 184 | } 185 | 186 | // SSL 187 | if (!empty($this->settings['auth']['requireSecure'])) { 188 | if (!isset($this->Controller->Security) 189 | || !is_object($this->Controller->Security)) { 190 | return $this->abort('You need to enable the Security component first'); 191 | } 192 | $this->Controller->Security->requireSecure($this->settings['auth']['requireSecure']); 193 | } 194 | 195 | // Set content-headers 196 | $this->headers(); 197 | } 198 | 199 | /** 200 | * Catch & fire callbacks. You can map callbacks to different places 201 | * using the value parts in $this->settings['callbacks']. 202 | * If the resolved callback is a string we assume it's in 203 | * the controller. 204 | * 205 | * @param string $name 206 | * @param array $arguments 207 | */ 208 | public function __call ($name, $arguments) { 209 | if (!isset($this->settings['callbacks'][$name])) { 210 | return $this->abort('Function does not exist: '. $name); 211 | } 212 | 213 | $cb = $this->settings['callbacks'][$name]; 214 | if (is_string($cb)) { 215 | $cb = array($this->Controller, $cb); 216 | } 217 | 218 | if (is_callable($cb)) { 219 | array_unshift($arguments, $this); 220 | return call_user_func_array($cb, $arguments); 221 | } 222 | } 223 | 224 | 225 | /** 226 | * Write the accumulated logentry 227 | * 228 | * @param $Controller 229 | */ 230 | public function shutdown (Controller $Controller) { 231 | if (!$this->isActive()) { 232 | return; 233 | } 234 | 235 | $this->log(array( 236 | 'responded' => date('Y-m-d H:i:s'), 237 | )); 238 | 239 | $this->log(true); 240 | } 241 | 242 | /** 243 | * Controls layout & view files 244 | * 245 | * @param $Controller 246 | * @return 247 | */ 248 | public function startup (Controller $Controller) { 249 | if (!$this->isActive()) { 250 | return; 251 | } 252 | 253 | // Rate Limit 254 | if (@$this->settings['ratelimit']['enable']) { 255 | $credentials = $this->credentials(); 256 | $class = @$credentials['class']; 257 | if (!$class) { 258 | $this->warning('Unable to establish class'); 259 | } else { 260 | list($time, $max) = $this->settings['ratelimit']['classlimits'][$class]; 261 | 262 | $cbMax = $this->cbRestRatelimitMax($credentials); 263 | if ($cbMax) { 264 | $max = $cbMax; 265 | } 266 | 267 | if (true !== ($count = $this->ratelimit($time, $max))) { 268 | $msg = sprintf( 269 | 'You have reached your ratelimit (%s is more than the allowed %s requests in %s)', 270 | $count, 271 | $max, 272 | str_replace('-', '', $time) 273 | ); 274 | $this->log('ratelimited', 1); 275 | return $this->abort($msg); 276 | } 277 | } 278 | } 279 | if ($this->settings['viewsFromPlugin']) { 280 | // Setup the controller so it can use 281 | // the view inside this plugin 282 | $this->Controller->viewClass = 'Rest.' . $this->View(false) . 'Encode'; 283 | } 284 | 285 | // Dryrun 286 | if (($this->Controller->_restMeta = @$_POST['meta'])) { 287 | if (@$this->Controller->_restMeta['dryrun']) { 288 | $this->warning('Dryrun active, not really executing your command'); 289 | $this->abort(); 290 | } 291 | } 292 | } 293 | 294 | /** 295 | * Collects viewVars, reformats, and makes them available as 296 | * viewVar: response for use in REST serialization 297 | * 298 | * @param $Controller 299 | * 300 | * @return 301 | */ 302 | public function beforeRender (Controller $Controller) { 303 | if (!$this->isActive()) return; 304 | 305 | if (false === ($extract = @$this->settings['actions'][$this->Controller->action]['extract'])) { 306 | $data = $this->Controller->viewVars; 307 | } else { 308 | $data = $this->inject( 309 | (array)$extract, 310 | $this->Controller->viewVars 311 | ); 312 | } 313 | 314 | $response = $this->response($data); 315 | 316 | $this->Controller->set(compact('response')); 317 | 318 | // if a callback function is requested, pass the callback name to the controller 319 | // responds if following query parameters present: jsoncallback, callback 320 | $callback = false; 321 | $json_callback_keys = array('jsoncallback', 'callback'); 322 | foreach ($json_callback_keys as $key) { 323 | if (array_key_exists($key, $this->Controller->params['url'])) { 324 | $callback = $this->Controller->params['url'][$key]; 325 | } 326 | } 327 | if ($callback) { 328 | if (preg_match('/\W/', $callback)) { 329 | return $this->abort('Prevented request. Your callback is vulnerable to XSS attacks. '); 330 | } 331 | $this->Controller->set('callbackFunc', $callback); 332 | } 333 | } 334 | 335 | /** 336 | * Determines is an array is numerically indexed 337 | * 338 | * @param array $array 339 | * 340 | * @return boolean 341 | */ 342 | public function numeric ($array = array()) { 343 | if (empty($array)) { 344 | return null; 345 | } 346 | $keys = array_keys($array); 347 | foreach ($keys as $key) { 348 | if (!is_numeric($key)) { 349 | return false; 350 | } 351 | } 352 | return true; 353 | } 354 | 355 | /** 356 | * Prepares REST data for cake interaction 357 | * 358 | * @param $data 359 | * @return 360 | */ 361 | protected function _modelizePost (&$data) { 362 | if (!is_array($data)) { 363 | return $data; 364 | } 365 | 366 | // Don't throw errors if data is already modelized 367 | // f.e. sending a serialized FormHelper form via ajax 368 | if (isset($data[$this->Controller->modelClass])) { 369 | $data = $data[$this->Controller->modelClass]; 370 | } 371 | 372 | // Protected against Saving multiple models in one post 373 | // while still allowing mass-updates in the form of: 374 | // $this->request->data[1][field] = value; 375 | if (Set::countDim($data) === 2) { 376 | if (!$this->numeric($data)) { 377 | return $this->error('2 dimensional can only begin with numeric index'); 378 | } 379 | } else if (Set::countDim($data) !== 1) { 380 | return $this->error('You may only send 1 dimensional posts'); 381 | } 382 | 383 | // Encapsulate in Controller Model 384 | $data = array( 385 | $this->Controller->modelClass => $data, 386 | ); 387 | 388 | return $data; 389 | } 390 | 391 | /** 392 | * Works together with Logging to ratelimit incomming requests by 393 | * identfield 394 | * 395 | * @return 396 | */ 397 | public function ratelimit ($time, $max) { 398 | // No rate limit active 399 | if (empty($this->settings['ratelimit'])) { 400 | return true; 401 | } 402 | 403 | // Need logging 404 | if (empty($this->settings['log']['model'])) { 405 | return $this->abort( 406 | 'Logging is required for any ratelimiting to work' 407 | ); 408 | } 409 | 410 | // Need identfield 411 | if (empty($this->settings['ratelimit']['identfield'])) { 412 | return $this->abort( 413 | 'Need a identfield or I will not know what to ratelimit on' 414 | ); 415 | } 416 | 417 | $userField = $this->settings['ratelimit']['identfield']; 418 | $userId = $this->credentials($userField); 419 | 420 | $this->cbRestlogBeforeFind(); 421 | if ($userId) { 422 | // If you're logged in 423 | $logs = $this->RestLog()->find('list', array( 424 | 'fields' => array('id', $userField), 425 | 'conditions' => array( 426 | $this->RestLog()->alias . '.requested >' => date('Y-m-d H:i:s', strtotime($time)), 427 | $this->RestLog()->alias . '.' . $userField => $userId, 428 | ), 429 | )); 430 | } else { 431 | // IP based rate limiting 432 | $max = $this->settings['ratelimit']['ip_limit']; 433 | $logs = $this->RestLog()->find('list', array( 434 | 'fields' => array('id', $userField), 435 | 'conditions' => array( 436 | $this->RestLog()->alias . '.requested >' => date('Y-m-d H:i:s', strtotime($time)), 437 | $this->RestLog()->alias . '.ip' => $this->_logData['ip'], 438 | ), 439 | )); 440 | } 441 | $this->cbRestlogAfterFind(); 442 | 443 | $count = count($logs); 444 | if ($count >= $max) { 445 | return $count; 446 | } 447 | 448 | return true; 449 | } 450 | 451 | /** 452 | * Return an instance of the log model 453 | * 454 | * @return object 455 | */ 456 | public function RestLog () { 457 | if (!$this->_RestLog) { 458 | $this->_RestLog = ClassRegistry::init($this->settings['log']['model']); 459 | $this->_RestLog->restLogSettings = $this->settings['log']; 460 | $this->_RestLog->restLogSettings['controller'] = $this->Controller->name; 461 | $this->_RestLog->Encoder = $this->View(true); 462 | } 463 | 464 | return $this->_RestLog; 465 | } 466 | 467 | /** 468 | * log(true) writes log to disk. otherwise stores key-value 469 | * pairs in memory for later saving. Can also work recursively 470 | * by giving an array as the key 471 | * 472 | * @param mixed $key 473 | * @param mixed $val 474 | * 475 | * @return boolean 476 | */ 477 | public function log ($key, $val = null, $scope = null) { 478 | // Write log 479 | if ($key === true && func_num_args() === 1) { 480 | if (!@$this->settings['log']['model']) { 481 | return true; 482 | } 483 | 484 | $this->RestLog()->create(); 485 | $this->cbRestlogBeforeSave(); 486 | 487 | $log = array( 488 | $this->RestLog()->alias => $this->_logData, 489 | ); 490 | $log = $this->cbRestlogFilter($log); 491 | 492 | if (is_array($log)) { 493 | $res = $this->RestLog()->save($log); 494 | } else { 495 | $res = null; 496 | } 497 | 498 | $this->cbRestlogAfterSave(); 499 | 500 | return $res; 501 | } 502 | 503 | // Multiple values: recurse 504 | if (is_array($key)) { 505 | foreach ($key as $k=>$v) { 506 | $this->log($k, $v); 507 | } 508 | return true; 509 | } 510 | 511 | // Single value, save 512 | $this->_logData[$key] = $val; 513 | return true; 514 | } 515 | 516 | /** 517 | * Sets or returns credentials as found in the 'Authorization' header 518 | * sent by the client. 519 | * 520 | * Have your client set a header like: 521 | * Authorization: TRUEREST username=john&password=xxx&apikey=247b5a2f72df375279573f2746686daa< 522 | * http://docs.amazonwebservices.com/AmazonS3/2006-03-01/index.html?RESTAuthentication.html 523 | * 524 | * credentials(true) sets credentials 525 | * credentials() returns full array 526 | * credentials('username') returns username 527 | * 528 | * @param mixed boolean or string $set 529 | * 530 | * @return 531 | */ 532 | public function credentials ($set = false) { 533 | // Return full credentials 534 | if ($set === false) { 535 | return $this->_credentials; 536 | } 537 | 538 | // Set credentials 539 | if ($set === true) { 540 | if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { 541 | $parts = explode(' ', $_SERVER['HTTP_AUTHORIZATION']); 542 | $match = array_shift($parts); 543 | if ($match !== $this->settings['auth']['keyword']) { 544 | return false; 545 | } 546 | $str = join(' ', $parts); 547 | parse_str($str, $this->_credentials); 548 | 549 | if (!isset($this->_credentials[$this->settings['auth']['fields']['class']])) { 550 | $this->_credentials[$this->settings['auth']['fields']['class']] = $this->settings['ratelimit']['default']; 551 | } 552 | 553 | $this->log(array( 554 | 'username' => @$this->_credentials[$this->settings['auth']['fields']['username']], 555 | 'apikey' => $this->_credentials[$this->settings['auth']['fields']['apikey']], 556 | 'class' => $this->_credentials[$this->settings['auth']['fields']['class']], 557 | )); 558 | } 559 | 560 | return $this->_credentials; 561 | } 562 | 563 | // Return 1 field 564 | if (is_string($set)) { 565 | // First try key as is 566 | if (null !== ($val = @$this->_credentials[$set])) { 567 | return $val; 568 | } 569 | 570 | // Fallback to the mapped key according to authfield settings 571 | if (null !== ($val = @$this->_credentials[$this->settings['auth']['fields'][$set]])) { 572 | return $val; 573 | } 574 | 575 | return null; 576 | } 577 | 578 | return $this->abort('credential argument not supported'); 579 | } 580 | 581 | /** 582 | * Returns a list of Controllers where Rest component has been activated 583 | * uses Cache::read & Cache::write by default to tackle performance 584 | * issues. 585 | * 586 | * @param boolean $cached 587 | * 588 | * @return array 589 | */ 590 | public function controllers ($cached = true) { 591 | $ckey = sprintf('%s.%s', __CLASS__, __FUNCTION__); 592 | 593 | if (!$cached || !($restControllers = Cache::read($ckey))) { 594 | $restControllers = array(); 595 | 596 | $controllers = App::objects('controller', null, false); 597 | 598 | // Unlist some controllers by default 599 | foreach ($this->settings['skipControllers'] as $skipController) { 600 | if (false !== ($key = array_search($skipController, $controllers))) { 601 | unset($controllers[$key]); 602 | } 603 | } 604 | 605 | // Instantiate all remaining controllers and check components 606 | foreach ($controllers as $controller) { 607 | $className = $controller; 608 | $controller = substr($controller, 0, -10); 609 | 610 | $debug = false; 611 | if (!class_exists($className)) { 612 | if (!App::import('Controller', $controller)) { 613 | continue; 614 | } 615 | } 616 | $Controller = new $className(); 617 | 618 | 619 | if (isset($Controller->components['Rest.Rest']['actions']) && is_array($Controller->components['Rest.Rest']['actions'])) { 620 | $exposeActions = array(); 621 | foreach ($Controller->components['Rest.Rest']['actions'] as $action => $vars) { 622 | if (!in_array($action, $Controller->methods)) { 623 | $this->debug(sprintf( 624 | 'Rest component is expecting a "%s" action but got "%s" instead. ' . 625 | 'You probably upgraded your component without reading the backward compatiblity ' . 626 | 'warnings in the readme file, or just did not implement the "%s" action in the "%s" controller yet', 627 | $Controller->name, 628 | $action, 629 | $action, 630 | $Controller->name 631 | )); 632 | continue; 633 | } 634 | $saveVars = array(); 635 | 636 | $exposeVars = array_merge( 637 | $this->settings['exposeVars']['*'], 638 | isset($this->settings['exposeVars'][$action]) ? $this->settings['exposeVars'][$action] : array() 639 | ); 640 | 641 | foreach ($exposeVars as $exposeVar => $example) { 642 | if (isset($vars[$exposeVar])) { 643 | $saveVars[$exposeVar] = $vars[$exposeVar]; 644 | } else { 645 | if (isset($this->settings['defaultVars'][$action][$exposeVar])) { 646 | $saveVars[$exposeVar] = $this->settings['defaultVars'][$action][$exposeVar]; 647 | } else { 648 | return $this->abort(sprintf( 649 | 'Rest maintainer needs to set "%s" for %s using ' . 650 | '%s->components->Rest.Rest->actions[\'%s\'][\'%s\'] = %s', 651 | $exposeVar, 652 | $action, 653 | $className, 654 | $action, 655 | $exposeVar, 656 | $example 657 | )); 658 | } 659 | } 660 | } 661 | $exposeActions[$action] = $saveVars; 662 | } 663 | 664 | $restControllers[$controller] = $exposeActions; 665 | } 666 | unset($Controller); 667 | } 668 | 669 | ksort($restControllers); 670 | 671 | if ($cached) { 672 | Cache::write($ckey, $restControllers); 673 | } 674 | } 675 | 676 | return $restControllers; 677 | } 678 | 679 | /** 680 | * Set content-type headers based on extension 681 | * 682 | * @param $ext 683 | * 684 | * @return 685 | */ 686 | public function headers ($ext = null) { 687 | return $this->View(true, $ext)->headers($this->Controller, $this->settings); 688 | } 689 | 690 | public function isActive () { 691 | if ($this->isActive === null) { 692 | if (!isset($this->Controller) || !is_object($this->Controller)) { 693 | return false; 694 | } 695 | 696 | if ($this->settings['onlyActiveWithAuth'] === true) { 697 | $keyword = $this->settings['auth']['keyword']; 698 | if ($keyword && strpos(@$_SERVER['HTTP_AUTHORIZATION'], $keyword) === 0) { 699 | return $this->isActive = true; 700 | } else { 701 | return $this->isActive = false; 702 | } 703 | } 704 | 705 | return $this->isActive = in_array( 706 | !empty($this->Controller->request->params['ext']) ? $this->Controller->request->params['ext'] : null, 707 | $this->settings['extensions'] 708 | ); 709 | } 710 | return $this->isActive; 711 | } 712 | public function validate ($format, $arg1 = null, $arg2 = null) { 713 | $args = func_get_args(); 714 | if (count($args) > 0) $format = array_shift($args); 715 | if (count($args) > 0) $format = vsprintf($format, $args); 716 | $this->_feedback['error'][] = 'validation: ' . $format; 717 | return false; 718 | } 719 | public function error ($format, $arg1 = null, $arg2 = null) { 720 | $args = func_get_args(); 721 | if (count($args) > 0) $format = array_shift($args); 722 | if (count($args) > 0) $format = vsprintf($format, $args); 723 | $this->_feedback[__FUNCTION__][] = $format; 724 | return false; 725 | } 726 | public function debug ($format, $arg1 = null, $arg2 = null) { 727 | $args = func_get_args(); 728 | if (count($args) > 0) $format = array_shift($args); 729 | if (count($args) > 0) $format = vsprintf($format, $args); 730 | $this->_feedback[__FUNCTION__][] = $format; 731 | return true; 732 | } 733 | public function info ($format, $arg1 = null, $arg2 = null) { 734 | $args = func_get_args(); 735 | if (count($args) > 0) $format = array_shift($args); 736 | if (count($args) > 0) $format = vsprintf($format, $args); 737 | $this->_feedback[__FUNCTION__][] = $format; 738 | return true; 739 | } 740 | public function warning ($format, $arg1 = null, $arg2 = null) { 741 | $args = func_get_args(); 742 | if (count($args) > 0) $format = array_shift($args); 743 | if (count($args) > 0) $format = vsprintf($format, $args); 744 | $this->_feedback[__FUNCTION__][] = $format; 745 | return false; 746 | } 747 | 748 | /** 749 | * Returns (optionally) formatted feedback. 750 | * 751 | * @param boolean $format 752 | * 753 | * @return array 754 | */ 755 | public function getFeedBack ($format = false) { 756 | if (!$format) { 757 | return $this->_feedback; 758 | } 759 | 760 | $feedback = array(); 761 | foreach ($this->_feedback as $level => $messages) { 762 | foreach ($messages as $i => $message) { 763 | $feedback[] = array( 764 | 'message' => $message, 765 | 'level' => $level, 766 | ); 767 | } 768 | } 769 | 770 | return $feedback; 771 | } 772 | 773 | /** 774 | * Reformats data according to Xpaths in $take 775 | * 776 | * @param array $take 777 | * @param array $viewVars 778 | * 779 | * @return array 780 | */ 781 | public function inject ($take, $viewVars) { 782 | $data = array(); 783 | foreach ($take as $path => $dest) { 784 | if (is_numeric($path)) { 785 | $path = $dest; 786 | } 787 | 788 | $data = Set::insert($data, $dest, Set::extract($path, $viewVars)); 789 | } 790 | 791 | return $data; 792 | } 793 | 794 | /** 795 | * Get an array of everything that needs to go into the Xml / Json 796 | * 797 | * @param array $data optional. Data collected by cake 798 | * 799 | * @return array 800 | */ 801 | public function response ($data = array()) { 802 | // In case of edit, return what post data was received 803 | if (empty($data) && !empty($this->postData)) { 804 | $data = $this->postData; 805 | 806 | // In case of add, enrich the postdata with the primary key of the 807 | // added record. Nice if you e.g. first create a parent, and then 808 | // immediately need the ID to add it's children 809 | if (!empty($this->Controller->modelClass)) { 810 | $modelClass = $this->Controller->modelClass; 811 | if (!empty($data[$modelClass]) && ($Model = @$this->Controller->{$modelClass})) { 812 | if (empty($data[$modelClass][$Model->primaryKey]) && $Model->id) { 813 | $data[$modelClass][$Model->primaryKey] = $Model->id; 814 | } 815 | } 816 | 817 | // import validation errors 818 | if (($modelErrors = @$this->Controller->{$modelClass}->validationErrors)) { 819 | if (is_array($modelErrors)) { 820 | $list = array(); 821 | foreach ($modelErrors as $key => $value) { 822 | if (is_array($value) && isset($value[0])) { 823 | $value = $value[0]; 824 | } 825 | if (!is_numeric($key)) { 826 | if (strpos($value, 'This field ') !== false) { 827 | $value = str_replace('This field ', Inflector::humanize($key) . ' ', $value); 828 | } else { 829 | $value = $key . ': ' . $value; 830 | } 831 | } 832 | $list[] = $value; 833 | } 834 | 835 | $modelErrors = join('; ', $list); 836 | } 837 | $this->validate($modelErrors); 838 | } 839 | } 840 | 841 | } 842 | $feedback = $this->getFeedBack(true); 843 | 844 | $hasErrors = count(@$this->_feedback['error']); 845 | $hasValidationErrors = count(@$this->_feedback['validate']); 846 | 847 | $time = time(); 848 | $status = ($hasErrors || $hasValidationErrors) 849 | ? 'error' 850 | : 'ok'; 851 | 852 | if (false === ($embed = @$this->settings['actions'][$this->Controller->action]['embed'])) { 853 | $response = $data; 854 | } else { 855 | $response = compact('data'); 856 | } 857 | 858 | if ($this->settings['meta']['enable']) { 859 | $serverKeys = array_flip($this->settings['meta']['requestKeys']); 860 | $server = array_intersect_key($_SERVER, $serverKeys); 861 | foreach ($server as $k=>$v) { 862 | if ($k === ($lc = strtolower($k))) { 863 | continue; 864 | } 865 | $server[$lc] = $v; 866 | unset($server[$k]); 867 | } 868 | 869 | $response['meta'] = array( 870 | 'status' => $status, 871 | 'feedback' => $feedback, 872 | 'request' => $server, 873 | 'credentials' => array(), 874 | 'time_epoch' => gmdate('U', $time), 875 | 'time_local' => date('r', $time), 876 | ); 877 | if (!empty($this->settings['version'])) { 878 | $response['meta']['version'] = $this->settings['version']; 879 | } 880 | 881 | foreach ($this->settings['auth']['fields'] as $field) { 882 | $response['meta']['credentials'][$field] = $this->credentials($field); 883 | } 884 | } 885 | 886 | $dump = array( 887 | 'data_in' => $this->postData, 888 | 'data_out' => $data, 889 | ); 890 | if ($this->settings['meta']['enable']) { 891 | $dump['meta'] = $response['meta']; 892 | } 893 | $this->log($dump); 894 | 895 | return $response; 896 | } 897 | 898 | /** 899 | * Returns either string or reference to active View object 900 | * 901 | * @param boolean $object 902 | * @param string $ext 903 | * 904 | * @return mixed object or string 905 | */ 906 | public function View ($object = true, $ext = null) { 907 | if (!$this->isActive()) { 908 | return $this->abort( 909 | 'Rest not activated. Maybe try correct extension.' 910 | ); 911 | } 912 | 913 | if ($ext === null) { 914 | $ext = $this->Controller->request->params['ext']; 915 | } 916 | 917 | $base = Inflector::camelize($ext); 918 | if (!$object) { 919 | return $base; 920 | } 921 | 922 | // Keep 1 instance of the active View in ->_View 923 | if (!$this->_View) { 924 | list($plugin, $viewClass) = pluginSplit('Rest.' . $base . 'Encode', true); 925 | $viewClass = $viewClass . 'View'; 926 | App::uses($viewClass, $plugin . 'View'); 927 | 928 | $this->_View = new $viewClass($this->Controller); 929 | } 930 | 931 | return $this->_View; 932 | } 933 | 934 | public function beforeRedirect (Controller $Controller, $url, $status = null, $exit = true) { 935 | if (@$this->settings['catchredir'] === false) { 936 | return; 937 | } 938 | 939 | if (!$this->isActive()) { 940 | return; 941 | } 942 | $redirect = true; 943 | $this->abort(compact('url', 'status', 'exit', 'redirect')); 944 | return false; 945 | } 946 | 947 | /** 948 | * Could be called by e.g. ->redirect to dump 949 | * an error & stop further execution. 950 | * 951 | * @param $params 952 | * @param $data 953 | */ 954 | public function abort ($params = array(), $data = array()) { 955 | if ($this->_aborting) { 956 | return; 957 | } 958 | $this->_aborting = true; 959 | if (is_string($params)) { 960 | $code = '403'; 961 | $error = $params; 962 | } else { 963 | $code = '200'; 964 | $error = ''; 965 | 966 | if (is_object($this->Controller->Session) && @$this->Controller->Session->read('Message.auth')) { 967 | // Automatically fetch Auth Component Errors 968 | $code = '403'; 969 | $error = $this->Controller->Session->read('Message.auth.message'); 970 | 971 | $this->Controller->Session->delete('Message.auth'); 972 | } 973 | 974 | if (!empty($params['status'])) { 975 | $code = $params['status']; 976 | } 977 | if (!empty($params['error'])) { 978 | $error = $params['error']; 979 | } 980 | 981 | if (empty($error) && !empty($params['redirect'])) { 982 | $this->debug('Redirect prevented by rest component. '); 983 | } 984 | } 985 | 986 | // Fallback to generic messages 987 | if (!$error && in_array($code, array(403, 404, 500))) { 988 | $error = $this->codes[$code]; 989 | } 990 | 991 | if ($error) { 992 | $this->error($error); 993 | } 994 | 995 | $this->Controller->response->statusCode($code); 996 | 997 | 998 | $this->headers(); 999 | 1000 | $encoded = $this->View()->encode($this->response($data)); 1001 | 1002 | // Die.. ugly. but very safe. which is what we need 1003 | // or all Auth & Acl work could be circumvented 1004 | $this->log(array( 1005 | 'httpcode' => $code, 1006 | 'error' => $error, 1007 | )); 1008 | $this->shutdown($this->Controller); 1009 | die($encoded); 1010 | } 1011 | } 1012 | -------------------------------------------------------------------------------- /Lib/BluntXml.php: -------------------------------------------------------------------------------- 1 | XML conversion. 5 | * No support for attributes. 6 | * Uses configurable tag to numerically indexed objects 7 | * 8 | * @author kvz 9 | * @author Jonathan Dalrymple 10 | */ 11 | class BluntXml { 12 | public $itemTag = 'item'; 13 | public $rootTag = 'root'; 14 | public $encoding = 'utf-8'; 15 | public $version = '1.1'; 16 | public $beautify = true; 17 | 18 | protected $_encodeBuffer = ''; 19 | 20 | /** 21 | * Decode xml string to multidimensional array 22 | * 23 | * @param string $xml 24 | * 25 | * @return array 26 | */ 27 | public function decode ($xml) { 28 | if (!($obj = simplexml_load_string($xml))) { 29 | return false; 30 | } 31 | 32 | $array = $this->_toArray($obj); 33 | $unitemized = $this->_unitemize($array); 34 | 35 | return $unitemized; 36 | } 37 | 38 | /** 39 | * Encode multidimensional array to xml string 40 | * 41 | * @param array $array 42 | * 43 | * @return string 44 | */ 45 | public function encode ($array, $rootTag = null) { 46 | if ($rootTag !== null) { 47 | $this->rootTag = $rootTag; 48 | } 49 | 50 | $this->_encodeBuffer = ''; 51 | $this->_toXml(array($this->rootTag => $array)); 52 | 53 | return $this->_xmlBeautify($this->_encodeBuffer); 54 | } 55 | 56 | /** 57 | * Strips out tags and nests children in sensible places 58 | * 59 | * @param array $array 60 | * 61 | * @return array 62 | */ 63 | protected function _unitemize ($array) { 64 | if (!is_array($array)) { 65 | return $array; 66 | } 67 | foreach ($array as $key => $val) { 68 | if (is_array($val)) { 69 | $array[$key] = $this->_unitemize($val); 70 | } 71 | 72 | if ($key === $this->itemTag && is_array($val)) { 73 | if ($this->_numeric($val)) { 74 | foreach ($val as $i => $v) { 75 | $array[] = $v; 76 | } 77 | } else { 78 | $array[] = $val; 79 | } 80 | unset($array[$this->itemTag]); 81 | } 82 | } 83 | return $array; 84 | } 85 | 86 | /** 87 | * SimpleXML Object to Array 88 | * 89 | * @param object $object 90 | * 91 | * @return array $array 92 | */ 93 | protected function _toArray ($object) { 94 | $array = array(); 95 | foreach ((array) $object as $key => $val) { 96 | if (is_object($val)) { 97 | if (count($val->children()) == 0) { 98 | $array[$key] = null; 99 | } else { 100 | $array[$key] = $this->_toArray($val); 101 | } 102 | } else if (is_array($val)) { 103 | $array[$key] = $this->_toArray($val); 104 | } else { 105 | $array[$key] = $this->_toArrayValue($val, $key); 106 | } 107 | } 108 | return $array; 109 | } 110 | 111 | /** 112 | * Takes unformatted xml string and beautifies it 113 | * 114 | * @param string $xml 115 | * 116 | * @return string 117 | */ 118 | protected function _xmlBeautify ($xml) { 119 | $xml = mb_convert_encoding($xml, 'UTF-8', 'HTML-ENTITIES'); 120 | 121 | if (!$this->beautify) { 122 | return $xml; 123 | } 124 | 125 | // Indentation 126 | $doc = new DOMDocument($this->version); 127 | $doc->preserveWhiteSpace = false; 128 | if (!$doc->loadXML(html_entity_decode($xml))) { 129 | trigger_error('Invalid XML: ' . $xml, E_USER_ERROR); 130 | } 131 | $doc->encoding = $this->encoding; 132 | $doc->formatOutput = true; 133 | 134 | return $doc->saveXML(); 135 | } 136 | 137 | /** 138 | * Recusively converts array to xml, itemizes numerically indexes arrays 139 | * 140 | * @param array $array 141 | * 142 | * @return string 143 | */ 144 | protected function _toXml ($array) { 145 | if (!is_array($array)) { 146 | $this->_encodeBuffer .= $this->_toXmlValue($array); 147 | return; 148 | } 149 | 150 | foreach ($array as $key => $val) { 151 | // starting tag 152 | if (!is_numeric($key)) { 153 | $this->_encodeBuffer .= sprintf("<%s>", $key); 154 | } 155 | // Another array 156 | if (is_array($val)){ 157 | // Handle non-associative arrays 158 | if ($this->_numeric($val)) { 159 | foreach ($val as $item) { 160 | $tag = $this->itemTag; 161 | 162 | $this->_encodeBuffer .= sprintf("<%s>", $tag); 163 | 164 | $this->_toXml($item); 165 | 166 | $this->_encodeBuffer .= sprintf("", $tag); 167 | } 168 | } else { 169 | $this->_toXml($val); 170 | } 171 | } else { 172 | $this->_encodeBuffer .= $this->_toXmlValue($val); 173 | } 174 | // Draw closing tag 175 | if (!is_numeric($key)) { 176 | $this->_encodeBuffer .= sprintf("", $key); 177 | } 178 | } 179 | } 180 | 181 | protected function _toXmlValue ($data) { 182 | if ($data === true) { 183 | return 'TRUE'; 184 | } 185 | if ($data === false) { 186 | return 'FALSE'; 187 | } 188 | if ($data === null) { 189 | return 'NULL'; 190 | } 191 | return htmlentities($data, ENT_COMPAT, 'UTF-8'); 192 | } 193 | 194 | protected function _toArrayValue ($data, $key = '') { 195 | if ($data === 'TRUE') { 196 | return true; 197 | } 198 | if ($data === 'FALSE') { 199 | return false; 200 | } 201 | if ($data === 'NULL') { 202 | return null; 203 | } 204 | return html_entity_decode($data, ENT_COMPAT, 'UTF-8'); 205 | } 206 | 207 | /** 208 | * Determines is an array is numerically indexed 209 | * 210 | * @param array $array 211 | * 212 | * @return boolean 213 | */ 214 | protected function _numeric ($array = array()) { 215 | if (empty($array)) { 216 | return null; 217 | } 218 | $keys = array_keys($array); 219 | foreach ($keys as $key) { 220 | if (!is_numeric($key)) { 221 | return false; 222 | } 223 | } 224 | return true; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /Model/RestAppModel.php: -------------------------------------------------------------------------------- 1 | $options 14 | * @return 15 | */ 16 | public function beforeSave ($options = array()) { 17 | $fields = (array)@$this->restLogSettings['fields']; 18 | $this->logpaths = array(); 19 | $this->filedata = array(); 20 | foreach ($fields as $field => $log) { 21 | if (false !== strpos($log, '.') || false !== strpos($log, '/') || false !== strpos($log, '{')) { 22 | $this->logpaths[$field] = $log; 23 | } 24 | } 25 | 26 | foreach ($this->data[__CLASS__] as $field => $val) { 27 | if (!is_scalar($this->data[__CLASS__][$field])) { 28 | $this->data[__CLASS__][$field] = $this->Encoder->encode($this->data[__CLASS__][$field], !!@$this->restLogSettings['pretty']); 29 | } 30 | if (is_null($this->data[__CLASS__][$field])) { 31 | $this->data[__CLASS__][$field] = ''; 32 | } 33 | 34 | if (isset($this->logpaths[$field])) { 35 | $this->filedata[$field] = $this->data[__CLASS__][$field]; 36 | $this->data[__CLASS__][$field] = '# on disk at: ' . $this->logpaths[$field]; 37 | } 38 | } 39 | 40 | return parent::beforeSave($options); 41 | } 42 | 43 | /** 44 | * Log fields to disk if necessary. Important to do after save so 45 | * we can also use the ->id in the filename. 46 | * 47 | * @param $created 48 | * @return 49 | */ 50 | public function afterSave ($created, $options = array()) { 51 | if (!$created) { 52 | return parent::beforeSave($created); 53 | } 54 | 55 | $vars = @$this->restLogSettings['vars'] ? @$this->restLogSettings['vars'] : array(); 56 | 57 | foreach ($this->filedata as $field => $val) { 58 | $vars['{' . $field . '}'] = $val; 59 | } 60 | foreach ($this->data[__CLASS__] as $field => $val) { 61 | $vars['{' . $field . '}'] = $val; 62 | } 63 | foreach (array('Y', 'm', 'd', 'H', 'i', 's', 'U') as $dp) { 64 | $vars['{date_' . $dp . '}'] = date($dp); 65 | } 66 | 67 | $vars['{LOGS}'] = LOGS; 68 | $vars['{id}'] = $this->id; 69 | $vars['{controller}'] = Inflector::tableize(@$this->restLogSettings['controller']); 70 | 71 | foreach ($this->filedata as $field => $val) { 72 | $vars['{field}'] = $field; 73 | $logfilepath = $this->logpaths[$field]; 74 | 75 | $logfilepath = str_replace(array_keys($vars), $vars, $logfilepath); 76 | $dir = dirname($logfilepath); 77 | if (!is_dir($dir)) { 78 | mkdir($dir, 0755, true); 79 | } 80 | file_put_contents($logfilepath, $val, FILE_APPEND); 81 | } 82 | 83 | return parent::beforeSave($created, $options); 84 | } 85 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #CakePHP Rest Plugin 2 | 3 | Painless REST server Plugin for CakePHP 4 | 5 | ## Background 6 | 7 | The [CakePHP REST Plugin](http://kvz.io/blog/2010/01/13/cakephp-rest-plugin-presentation/) 8 | takes whatever your existing controller actions 9 | gather in viewvars, reformats it in json, csv or xml, and outputs it to the 10 | client. Because you hook it into existing actions, you only have to write your 11 | features once, and this plugin will just unlock them as API. The plugin knows 12 | it’s being called by looking at the extension in the url: `.json`, `.csv` or 13 | `.xml` and optionally at the `Authorization:` header. 14 | 15 | So, if you’ve already coded `/servers/reboot/2`, you can have: 16 | 17 | - `/servers/reboot/2.json` 18 | - `/servers/reboot/2.xml` 19 | 20 | ..up & running in no time. 21 | 22 | CakePHP REST Plugin can even change the structure of your existing viewvars 23 | using bi-directional xpaths. This way you can extract info using an xpath, and 24 | output it to your API clients using another xpath. If this doesn’t make any 25 | sense, please have a look at the examples. 26 | 27 | You can attach the `RestComponent` to a controller, but you can limit 28 | REST activity to a single action. 29 | 30 | For best results, 2 changes to your application have to be made: 31 | 32 | - A check if REST is active inside your error handler & `redirect()` 33 | - Resource mapping in your router (see docs below) 34 | 35 | ## Warning - Cake 1.3 Users 36 | 37 | Please use the [cake-1.3 38 | branch](https://github.com/kvz/cakephp-rest-plugin/tree/cake-1.3). 39 | As of November 5th 2012, \`master\` now points to Cake 2.0+ code. 40 | 41 | ## Warning - Backwards compatibility breakage 42 | 43 | Action variables are now all contained in 1 big ‘actions’ setting, instead 44 | of directly under settings, as to avoid setting vs action collision. 45 | Behavior changed since [pull 15](https://github.com/kvz/cakephp-rest- 46 | plugin/pull/15) 47 | If you don’t change your controllers to reflect that, your API will break. 48 | 49 | [This](https://github.com/kvz/cakephp-rest-plugin/commit/e70728fe98ac442d546e08836a5b388aff0ef1ec) 50 | is your last *good* version. These settings have moved likewise: 51 | 52 | - `->{$action}['extract']` `->actions[$action]['extract']` 53 | - `->{$action}['id']` `->actions[$action]['id']` 54 | - `->{$action}['scopeVar']` `->actions[$action]['scopeVar']` 55 | - `->{$action}['method']` `->actions[$action]['method']` 56 | - Ratelimiter is now toggled with `->ratelimit['enable']` instead of `->ratelimiter` 57 | 58 | ## Requirements 59 | 60 | - PHP 5.2.6+ or the PECL json package 61 | - CakePHP 2.0+ 62 | 63 | ## Installation 64 | 65 | - Download this: 66 | [http://github.com/kvz/cakephp-rest-plugin/zipball/master](http://github.com/kvz/cakephp-rest-plugin/zipball/master) 67 | - Unzip that download. 68 | - Copy the resulting folder to app/plugins 69 | - Rename the folder you just copied to `rest` 70 | 71 | ### GIT Submodule 72 | 73 | In your app directory type: 74 | 75 | ```bash 76 | git submodule add git://github.com/kvz/cakephp-rest-plugin.git Plugins/Rest 77 | git submodule init 78 | git submodule update 79 | ``` 80 | 81 | ### GIT Clone 82 | 83 | In your plugin directory type 84 | 85 | ```bash 86 | git clone git://github.com/kvz/cakephp-rest-plugin.git Rest 87 | ``` 88 | 89 | ### Apache 90 | 91 | Do you run Apache? Make your `app/webroot/.htaccess` look like so: 92 | 93 | ```bash 94 | 95 | RewriteEngine On 96 | RewriteCond %{REQUEST_FILENAME} !-d 97 | RewriteCond %{REQUEST_FILENAME} !-f 98 | RewriteRule ^(.*)$ index.php?url=$1 [QSA,L] 99 | 100 | # Adds AUTH support to Rest Plugin: 101 | RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization},last] 102 | 103 | ``` 104 | 105 | In my experience Nginx & FastCGI already make the HTTP\_AUTHORIZATION 106 | available which is used to parse credentials for authentication. 107 | 108 | Usage 109 | ----- 110 | 111 | ### Controller 112 | 113 | Beware that you can no longer use `$this->render()` yourself 114 | 115 | ```php 116 | array( 121 | 'catchredir' => true, // Recommended unless you implement something yourself 122 | 'debug' => 0, 123 | 'actions' => array( 124 | 'view' => array( 125 | 'extract' => array('server.Server' => 'servers.0'), 126 | ), 127 | 'index' => array( 128 | 'extract' => array('rows.{n}.Server' => 'servers'), 129 | ), 130 | ), 131 | ), 132 | ); 133 | 134 | /** 135 | * Shortcut so you can check in your Controllers wether 136 | * REST Component is currently active. 137 | * 138 | * Use it in your ->flash() methods 139 | * to forward errors to REST with e.g. $this->Rest->error() 140 | * 141 | * @return boolean 142 | */ 143 | protected function _isRest() { 144 | return !empty($this->Rest) && is_object($this->Rest) && $this->Rest->isActive(); 145 | } 146 | } 147 | ?> 148 | ``` 149 | 150 | `extract` extracts variables you have in: `$this->viewVars` and makes them 151 | available in the resulting XML or JSON under the name you specify in the value 152 | part. 153 | 154 | Here’s a more simple example of how you would use the viewVar `tweets` 155 | **as-is**: 156 | 157 | ```php 158 | array( 162 | 'catchredir' => true, 163 | 'actions' => array( 164 | 'extract' => array( 165 | 'index' => array('tweets'), 166 | ), 167 | ), 168 | ), 169 | ); 170 | 171 | public function index() { 172 | $tweets = $this->_getTweets(); 173 | $this->set(compact('tweets')); 174 | } 175 | } 176 | ?> 177 | ``` 178 | 179 | And when asked for the xml version, Rest Plugin would return this to 180 | your clients: 181 | 182 | ```xml 183 | 184 | 185 | 186 | ok 187 | 188 | 189 | ok 190 | info 191 | 192 | 193 | 194 | GET 195 | /tweets/index.xml 196 | HTTP/1.1 197 | 123.123.123.123 198 | 123.123.123.123 199 | www.example.com 200 | My API Client 1.0 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 123 213 | looking forward to the finals! 214 | 215 | 216 | 123 217 | i need a drink 218 | 219 | 220 | 221 | 222 | ``` 223 | 224 | As you can see, the controller name + response is always the root element (for 225 | json there is no root element). Then the content is divived in `meta` & 226 | `data`, and the latter is where your actual viewvars are stored. Meta is there 227 | to show any information regarding the validity of the request & response. 228 | 229 | ### Authorization 230 | 231 | Check the HTTP header as shown [here](http://docs.amazonwebservices.com/Amazon 232 | S3/latest/dev/index.html?RESTAuthentication.html). You can control the 233 | `authKeyword` setting to control what keyword belongs to your REST API. By 234 | default it uses: TRUEREST. Have your users supply a header like: 235 | `Authorization: TRUEREST 236 | username=john&password=xxx&apikey=247b5a2f72df375279573f2746686daa` 237 | 238 | Now, inside your controller these variables will be available by calling 239 | `$this->Rest->credentials()`. This plugin only handles the parsing of the 240 | header, and passes the info on to your app. So login anyone with e.g. 241 | `$this->Auth->login()` and the information you retrieved from 242 | `$this->Rest->credentials()`; 243 | 244 | Example: 245 | 246 | ```php 247 | Auth->user()) { 253 | // Try to login user via REST 254 | if ($this->Rest->isActive()) { 255 | $this->Auth->autoRedirect = false; 256 | $data = array( 257 | $this->Auth->userModel => array( 258 | 'username' => $credentials['username'], 259 | 'password' => $credentials['password'], 260 | ), 261 | ); 262 | $data = $this->Auth->hashPasswords($data); 263 | if (!$this->Auth->login($data)) { 264 | $msg = sprintf('Unable to log you in with the supplied credentials. '); 265 | return $this->Rest->abort(array('status' => '403', 'error' => $msg)); 266 | } 267 | } 268 | } 269 | parent::beforeFilter(); 270 | } 271 | } 272 | ?> 273 | ``` 274 | 275 | ### Schema 276 | 277 | If you’re going to make use of this plugin’s Logging & Ratelimitting (default) 278 | and you should run the database schema found in: 279 | `config/schema/rest_logs.sql`. 280 | 281 | ### Router 282 | 283 | ```php 284 | 292 | ``` 293 | 294 | ### Callbacks 295 | 296 | If you’re using the built-in ratelimiter, you may still want a little control 297 | yourself. I provide that in the form of 4 callbacks: 298 | 299 | ```php 300 | 310 | ``` 311 | 312 | That will be called in you AppController if they exists. 313 | 314 | You may want to give a specific user a specific ratelimit. In that case you 315 | can use the following callback in your User Model: 316 | 317 | ```php 318 | 325 | ``` 326 | 327 | And for that user the return value of the callback will be used instead of the 328 | general class limit you could have specified in the settings. 329 | 330 | ### Customizing callback 331 | 332 | You can map callbacks to different places using the `callbacks` setting 333 | like so: 334 | 335 | ```php 336 | array( 340 | 'catchredir' => true, 341 | 'callbacks' => array( 342 | 'cbRestlogBeforeSave' => 'restlogBeforeSave', 343 | 'cbRestlogAfterSave' => 'restlogAfterSave', 344 | 'cbRestlogBeforeFind' => 'restlogBeforeFind', 345 | 'cbRestlogAfterFind' => array('Common', 'setCache'), 346 | 'cbRestlogFilter' => 'restlogFilter', 347 | 'cbRestRatelimitMax' => 'restRatelimitMax', 348 | ), 349 | ), 350 | ); 351 | } 352 | ?> 353 | ``` 354 | 355 | If the resolved callback is a string we assume it’s a method in the 356 | calling controller. 357 | 358 | Here’s an example of the logFilter callback 359 | 360 | ```php 361 | $Rest 366 | * @param $log 367 | * 368 | * @return 369 | */ 370 | public function restlogFilter ($Rest, $log) { 371 | if (Configure::read('App.short') === 'truecare') { 372 | // You could also do last minute changes to the data being logged 373 | return $log; 374 | } 375 | // Or return false to prevent logging alltogether 376 | return false; 377 | } 378 | ?> 379 | ``` 380 | 381 | ### Logs 382 | 383 | We can use this in simple way 384 | 385 | ```php 386 | public $components = array( 387 | 'Rest.Rest' => array( 388 | 'catchredir' => true, 389 | 'log' => array( 390 | 'model' => 'Rest.RestLog', 391 | 'pretty' => true, 392 | ), 393 | 'actions' => array( 394 | 'index' => array( 395 | 'extract' => array( 'orders' ), 396 | ), 397 | 'view' => array( 398 | 'extract' => array( 'orderDetail' ), 399 | ), 400 | 'add' => array( 401 | 'extract' => array( 'message' ), 402 | ), 403 | ), 404 | ), 405 | ); 406 | ``` 407 | 408 | And in AppController 409 | 410 | ```php 411 | public function restlogFilter ($Rest, $log) { 412 | return $log; 413 | } 414 | ``` 415 | 416 | Optionally in (your own, maybe extended) Model you could define extra db config 417 | 418 | ```php 419 | App::import('Rest.RestLog', 'model'); 420 | class ApiLog extends RestLog { 421 | public $useDbConfig = 'mongo'; 422 | ``` 423 | 424 | ### Configuration 425 | 426 | You can chose to override Rest’s default configuration using a global: 427 | 428 | ```php 429 | '0.3', 432 | 'log' => array( 433 | 'vars' => array( 434 | '{environment}' => Configure::read('App.short') . '-' . Configure::read('App.environment'), 435 | ), 436 | 'pretty' => false, 437 | // Optionally, choose to store some log fields on disk, instead of in the database 438 | 'fields' => array( 439 | 'data_in' => '/var/log/rest/{environment}/{controller}/{date_Y}_{date_m}/{username}_{id}.log', 440 | 'meta' => '/var/log/rest/{environment}/{controller}/{date_Y}_{date_m}/{username}_{id}.log', 441 | 'data_out' => '/var/log/rest/{environment}/{controller}/{date_Y}_{date_m}/{username}_{id}.log', 442 | ), 443 | ), 444 | )); 445 | ?> 446 | ``` 447 | 448 | And you can override that on a per-controller basis like so: 449 | 450 | ```php 451 | array( 455 | 'log' => array( 456 | 'pretty' => true, 457 | ), 458 | ), 459 | ); 460 | } 461 | ?> 462 | ``` 463 | 464 | So: 465 | 466 | Rest default \< Global Rest.settings config \< Controller Rest.Rest 467 | component settings 468 | 469 | ### JSONP support 470 | 471 | [Thanks to](https://github.com/kvz/cakephp-rest-plugin/pull/3#issuecomment-883201) 472 | [Chris Toppon](http://www.supermethod.com/), there now also is 473 | [JSONP](http://en.wikipedia.org/wiki/JSON#JSONP) support out of the box. 474 | 475 | No extra PHP code or configuration is required on the server side with this 476 | patch, just supply either the parameter `callback` or `jsoncallback` to the 477 | JSON url provided by your plugin and the output will be wrapped in mycallback 478 | as a function. 479 | 480 | For example: 481 | 482 | ```html 483 | 488 | 489 | ``` 490 | 491 | With jQuery, something similar could have been achieved like so: 492 | 493 | ```javascript 494 | jQuery.getJSON('http://www.yourdomain.com/products/product.json', function (data) { 495 | alert('Product: ' + data.product.name + ', Price: ' + data.product.price); 496 | }); 497 | ``` 498 | 499 | But for cross-domain requests, use JSONP. jQuery will substitute `?` 500 | with the callback. 501 | 502 | ```javascript 503 | jQuery.getJSON('http://www.yourdomain.com/products/product.json?callback=?', function (data) { 504 | alert('Product: ' + data.product.name + ', Price: ' + data.product.price); 505 | }); 506 | ``` 507 | 508 | Good explanations of typical JSONP usage here: 509 | 510 | - [What is JSONP?](http://remysharp.com/2007/10/08/what-is-jsonp/) 511 | - [Cross-domain communications with JSONP, Part 1: Combine JSONP and jQuery to quickly build powerful mashups](http://www.ibm.com/developerworks/library/wa-aj-jsonp1/) 512 | 513 | ## Todo 514 | 515 | - More testing 516 | - ~~Cake 2.0 support~~ 517 | - ~~Cake 1.3 support~~ 518 | - ~~The RestLog model that tracks usage should focus more on IP for 519 | rate-limiting than account info. This is mostly to defend against 520 | denial of server & brute force attempts~~ 521 | - ~~Maybe some Refactoring. This is pretty much the first attempt at a 522 | working plugin~~ 523 | - ~~XML (now only JSON is supported)~~ 524 | 525 | ## Resources 526 | 527 | This plugin was based on: 528 | 529 | - [Priminister’s API presentation during CakeFest 03 Berlin](http://www.cake-toppings.com/2009/07/15/cakefest-berlin/) 530 | - [The help of Jonathan Dalrymple](http://github.com/veritech) 531 | - [REST documentation](http://book.cakephp.org/view/476/REST) 532 | - [CakeDC article](http://cakedc.com/eng/developer/mark_story/2008/12/02/nate-abele-restful-cakephp) 533 | 534 | I held a presentation on this plugin during the first Dutch CakePHP 535 | meetup: 536 | 537 | - [REST presentation at slideshare](http://www.slideshare.net/kevinvz/rest-presentation-2901872) 538 | 539 | I’m writing a client side API that talks to this plugin for the company 540 | I work for. 541 | If you’re looking to provide your customers with something similar, 542 | it may be helpful to [have a look at it](http://github.com/true/true-api). 543 | 544 | ## Other 545 | 546 | ### Leave comments 547 | 548 | [On my blog](http://kvz.io/blog/2010/01/13/cakephp-rest-plugin-presentation/) 549 | 550 | ### Leave money ;) 551 | 552 | Like this plugin? Consider [a small donation](https://flattr.com/thing/68756/cakephp-rest-plugin) 553 | 554 | Love this plugin? Consider [a big donation](http://pledgie.com/campaigns/12581) :) 555 | 556 | ## License 557 | 558 | Licensed under MIT 559 | 560 | Copyright © 2009-2011, Kevin van Zonneveld 561 | All rights reserved. 562 | 563 | Redistribution and use in source and binary forms, with or without 564 | modification, are permitted provided that the following conditions are 565 | met: 566 | \* Redistributions of source code must retain the above copyright 567 | notice, this list of conditions and the following disclaimer. 568 | \* Redistributions in binary form must reproduce the above copyright 569 | notice, this list of conditions and the following disclaimer in the 570 | documentation and/or other materials provided with the distribution. 571 | \* Neither the name of the this plugin nor the names of its 572 | contributors may be used to endorse or promote products derived from 573 | this software without specific prior written permission. 574 | 575 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” 576 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 577 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 578 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY DIRECT, 579 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 580 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 581 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 582 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 583 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, 584 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 585 | -------------------------------------------------------------------------------- /Test/Case/Component/RestComponentTest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | App::import('Controller', 'Component'); 16 | App::import('Component', array('Rest.Rest', 'Auth')); 17 | App::import('Controller', 'AppController'); 18 | 19 | /** 20 | * Fake methods to reflecit things in testcase 21 | * and/org access to protected methods (better not) 22 | * 23 | */ 24 | class MockRestComponent extends RestComponent { 25 | /** 26 | * Let's not really set headers in testmode 27 | * 28 | * @param $ext 29 | */ 30 | public function headers($ext = false) { 31 | return null; 32 | } 33 | } 34 | 35 | class TestRestController extends AppController { 36 | public $components = array('RequestHandler', 'MockRest'); 37 | public $uses = array(); 38 | } 39 | 40 | /** 41 | * Rest Component Test Case 42 | * 43 | * @author Kevin van Zonneveld 44 | */ 45 | class RestComponentTest extends CakeTestCase { 46 | 47 | public $settings = array( 48 | 'debug' => 2, 49 | 'extensions' => array('xml', 'json'), 50 | 'view' => array( 51 | 'extract' => array('server.DnsDomain' => 'dns_domains.0'), 52 | ), 53 | 'index' => array( 54 | 'extract' => array('rows.{n}.DnsDomain' => 'dns_domains'), 55 | ), 56 | ); 57 | 58 | public function setUp() { 59 | parent::setUp(); 60 | $request = new CakeRequest(null, false); 61 | $request->params['ext'] = 'json'; 62 | 63 | $this->Controller = new TestRestController($request, $this->getMock('CakeResponse')); 64 | 65 | $collection = new ComponentCollection(); 66 | $collection->init($this->Controller); 67 | $this->Rest = new MockRestComponent($collection, $this->settings); 68 | $this->Rest->request = $request; 69 | $this->Rest->response = $this->getMock('CakeResponse'); 70 | 71 | $this->Controller->Components->init($this->Controller); 72 | } 73 | 74 | public function testInitialize() { 75 | $this->Rest->initialize($this->Controller); 76 | $this->assertEqual($this->Rest->settings['debug'], $this->settings['debug']); 77 | } 78 | 79 | public function testIsActive() { 80 | $this->Rest->initialize($this->Controller); 81 | $this->assertTrue($this->Rest->isActive()); 82 | 83 | $this->Rest->isActive = false; 84 | $this->assertFalse($this->Rest->isActive()); 85 | 86 | $this->Rest->isActive = null; 87 | $this->assertTrue($this->Rest->isActive()); 88 | } 89 | 90 | public function testControllers() { 91 | //prd($this->Rest->controllers()); 92 | } 93 | 94 | public function tearDown() { 95 | parent::tearDown(); 96 | unset($this->Rest, $this->Controller); 97 | 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Test/Case/Helper/RestXmlHelperTest.php: -------------------------------------------------------------------------------- 1 | RestXml = new RestXmlHelper(); 11 | } 12 | 13 | function testSerializeInt() { 14 | $result = $this->RestXml->serialize(1); 15 | $this->assertEqual($result, '1'); 16 | } 17 | 18 | function testSerializeString() { 19 | $result = $this->RestXml->serialize('This is a string'); 20 | $this->assertEqual($result, 'This is a string'); 21 | } 22 | 23 | function testSerializeWithName() { 24 | $result = $this->RestXml->serialize('Another String', 'name'); 25 | $this->assertEqual($result, 'Another String'); 26 | } 27 | 28 | function testSerializeBoolean() { 29 | $result = $this->RestXml->serialize(true); 30 | $this->assertEqual($result, 'true'); 31 | } 32 | 33 | function testSerializeBooleanFalse() { 34 | $result = $this->RestXml->serialize(false); 35 | $this->assertEqual($result, 'false'); 36 | } 37 | 38 | function testSerializeDatetime() { 39 | $result = $this->RestXml->serialize('2009-04-07T01:48:04Z'); 40 | $this->assertEqual($result, '2009-04-07T01:48:04Z'); 41 | $result = $this->RestXml->serialize('2009-04-07'); 42 | $this->assertEqual($result, '2009-04-07T00:00:00Z'); 43 | } 44 | 45 | function testSerializeStdClass() { 46 | $data = new stdClass(); 47 | $data->name = 'Rodrigo Moyle'; 48 | $data->description = 'Lorem ipsum dolor sit amet.'; 49 | $result = $this->RestXml->serialize($data, 'user'); 50 | $this->assertTags($result, array( 51 | ' 'Rodrigo Moyle', 65 | 'description' => 'Lorem ipsum dolor sit amet.', 66 | ); 67 | $result = $this->RestXml->serialize($data, 'user'); 68 | $this->assertTags($result, array( 69 | 'RestXml->serialize($data, 'users'); 86 | $this->assertTags($result, array( 87 | 'users' => array('type' => 'array'), 88 | 'user' => array('type' => 'integer'), 89 | '1', 90 | '/user', 91 | ' array( 101 | 'name' => 'Rodrigo Moyle' 102 | ) 103 | ); 104 | $result = $this->RestXml->serialize($data); 105 | $this->assertTags($result, array( 106 | ' array( 118 | 'name' => 'Rodrigo Moyle' 119 | ) 120 | ), 121 | array( 122 | 'User' => array( 123 | 'name' => 'Another User' 124 | ) 125 | ) 126 | ); 127 | $expected = array( 128 | 'users' => array('type' => 'array'), 129 | 'RestXml->serialize($data, 'users'); 142 | $this->assertTags($result, $expected, true); 143 | $result = $this->RestXml->serialize($data); 144 | $this->assertTags($result, $expected, true); 145 | } 146 | 147 | function testSerializeCamelCaseName() { 148 | $result = $this->RestXml->serialize('Rodrigo Moyle', 'UserName'); 149 | $this->assertEqual($result, 'Rodrigo Moyle'); 150 | } 151 | 152 | function testSerializeUnderscoredName() { 153 | $result = $this->RestXml->serialize('Rodrigo Moyle', 'user_name'); 154 | $this->assertEqual($result, 'Rodrigo Moyle'); 155 | } 156 | 157 | function testSerializeCamelCaseNameInArray() { 158 | $data = array('UserName' => 'Rodrigo Moyle'); 159 | $result = $this->RestXml->serialize($data); 160 | $this->assertTags($result, array( 161 | 'RestXml->serialize($author); 172 | $this->assertTags($result, array( 173 | 'assertEqual($this->RestXml->_getType($value), $type); 188 | } 189 | } 190 | 191 | function testGetTypeWithDatetime() { 192 | $datetime = '2009-04-07T01:48:04Z'; 193 | $result = $this->RestXml->_getType($datetime); 194 | $this->assertEqual($result, 'datetime'); 195 | $datetime = '2009-04-07 01:48:04'; 196 | $result = $this->RestXml->_getType($datetime); 197 | $this->assertEqual($result, 'datetime'); 198 | } 199 | 200 | function testGetTypeWithDate() { 201 | $date = '2009-04-07'; 202 | $result = $this->RestXml->_getType($date); 203 | $this->assertEqual($result, 'datetime'); 204 | } 205 | 206 | function testGetTypeWithTime() { 207 | $time = '01:48:04'; 208 | $result = $this->RestXml->_getType($time); 209 | // Should datetime or time? 210 | $this->assertEqual($result, 'string'); 211 | $time = '01:48'; 212 | $result = $this->RestXml->_getType($time); 213 | // Should datetime or time? 214 | $this->assertEqual($result, 'string'); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /View/CsvEncodeView.php: -------------------------------------------------------------------------------- 1 | viewVars)) { 10 | trigger_error( 11 | 'viewVar "response" should have been set by Rest component already', 12 | E_USER_ERROR 13 | ); 14 | return false; 15 | } 16 | 17 | return $this->encode($this->viewVars['response']); 18 | } 19 | 20 | public function headers ($Controller, $settings) { 21 | if ($settings['debug'] > 2) { 22 | return null; 23 | } 24 | 25 | header('Content-Type: text/csv'); 26 | $Controller->RequestHandler->respondAs('csv'); 27 | return true; 28 | } 29 | 30 | /** 31 | * create csv string from response 32 | * 33 | * @param array $response with 'meta' and 'data' part 34 | * 35 | * @return string 36 | **/ 37 | public function encode ($response) { 38 | // if status ok then remove meta part. If not ok then only show status and feedback message 39 | if ($response['meta']['status'] === 'ok') { 40 | unset($response['meta']); 41 | } else { 42 | return 'status: '.$response['meta']['status'] . "\n" . 43 | 'message:'. $response['meta']['feedback']['message'] . "\n"; 44 | } 45 | 46 | // set everything from data part to one single one dimensional array 47 | $data = $response['data'][$this->request->params['controller']]; 48 | unset($response); 49 | // put headers from array keys as first row 50 | $fields = array_keys($data[0]); 51 | array_unshift($data, $fields); 52 | // now make the csv file 53 | $csv = ''; 54 | foreach ($data AS $rec) { 55 | $csv .= $this->_putcsv($rec); 56 | } 57 | return $csv; 58 | } 59 | 60 | /** 61 | * Creating a file resource to php://temp so we don;t save a real file and 62 | * return the string of that csv line 63 | * 64 | * @param array $row 65 | * @param string $delimiter 66 | * @param string $enclosure 67 | * @param string $eol 68 | * 69 | * @return string 70 | */ 71 | protected function _putcsv ($row, $delimiter = ';', $enclosure = '"', $eol = "\n") { 72 | static $fp = false; 73 | if ($fp === false) { 74 | $fp = fopen('php://temp', 'r+'); 75 | } else { 76 | rewind($fp); 77 | } 78 | 79 | if (fputcsv($fp, $row, $delimiter, $enclosure) === false) { 80 | return false; 81 | } 82 | rewind($fp); 83 | $csv = fgets($fp); 84 | if ($eol != PHP_EOL) { 85 | $csv = substr($csv, 0, (0 - strlen(PHP_EOL))) . $eol; 86 | } 87 | return $csv; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /View/JsonEncodeView.php: -------------------------------------------------------------------------------- 1 | viewVars)) { 16 | trigger_error( 17 | 'viewVar "response" should have been set by Rest component already', 18 | E_USER_ERROR 19 | ); 20 | return false; 21 | } 22 | // JSONP: Wrap in callback function if requested 23 | if (array_key_exists('callbackFunc', $this->viewVars)) { 24 | return $this->viewVars['callbackFunc'] . '(' . $this->encode($this->viewVars['response']) . ');'; 25 | } else { 26 | return $this->encode($this->viewVars['response']); 27 | } 28 | } 29 | 30 | public function headers ($Controller, $settings) { 31 | if ($settings['debug'] > 2) { 32 | return null; 33 | } 34 | 35 | header('Content-Type: application/json'); 36 | $Controller->RequestHandler->respondAs('json'); 37 | return true; 38 | } 39 | 40 | public function encode ($response, $pretty = false) { 41 | if ($pretty) { 42 | $encoded = $this->_encode($response); 43 | $pretty = $this->json_format($encoded); 44 | return $pretty; 45 | } 46 | return $this->_encode($response); 47 | } 48 | 49 | 50 | /** 51 | * (Recursively) utf8_encode each value in an array. 52 | * 53 | * http://www.php.net/manual/es/function.utf8-encode.php#75422 54 | * 55 | * @param array $array 56 | * @return array utf8_encoded 57 | */ 58 | public function utf8_encode_array ($array) { 59 | if (!is_array($array)) { 60 | return false; // argument is not an array, return false 61 | } 62 | 63 | $result_array = array(); 64 | 65 | foreach ($array as $key => $value) { 66 | if ($this->array_type($array) === 'map') { 67 | // encode both key and value 68 | 69 | if (is_array($value)) { 70 | // recursion 71 | $result_array[utf8_encode($key)] = $this->utf8_encode_array($value); 72 | } else { 73 | // no recursion 74 | if (is_string($value)) { 75 | $result_array[utf8_encode($key)] = utf8_encode($value); 76 | } else { 77 | // do not re-encode non-strings, just copy data 78 | $result_array[utf8_encode($key)] = $value; 79 | } 80 | } 81 | } else if ($this->array_type($array) === 'vector') { 82 | // encode value only 83 | 84 | if (is_array($value)) { 85 | // recursion 86 | $result_array[$key] = $this->utf8_encode_array($value); 87 | } else { 88 | // no recursion 89 | if (is_string($value)) { 90 | $result_array[$key] = utf8_encode($value); 91 | } else { 92 | // do not re-encode non-strings, just copy data 93 | $result_array[$key] = $value; 94 | } 95 | } 96 | } 97 | } 98 | 99 | return $result_array; 100 | } 101 | 102 | /** 103 | * Determines array type ("vector" or "map"). Returns false if not an array at all. 104 | * (I hope a native function will be introduced in some future release of PHP, because 105 | * this check is inefficient and quite costly in worst case scenario.) 106 | * 107 | * http://www.php.net/manual/es/function.utf8-encode.php#75422 108 | * 109 | * @param array $array The array to analyze 110 | * @return string array type ("vector" or "map") or false if not an array 111 | */ 112 | public function array_type ($array) { 113 | if (!is_array($array)) { 114 | return false; 115 | } 116 | 117 | $next = 0; 118 | $return_value = 'vector'; // we have a vector until proved otherwise 119 | 120 | foreach ($array as $key => $value) { 121 | if ($key != $next) { 122 | $return_value = 'map'; // we have a map 123 | break; 124 | } 125 | 126 | $next++; 127 | } 128 | 129 | return $return_value; 130 | } 131 | 132 | /** 133 | * PHP version independent json_encode 134 | * 135 | * Adapted from http://www.php.net/manual/en/function.json-encode.php#82904. 136 | * Author: Steve (30-Apr-2008 05:35) 137 | * 138 | * 139 | * @staticvar array $jsonReplaces 140 | * @param array $response 141 | * 142 | * @return string 143 | */ 144 | public function _encode ($response) { 145 | $utf8_encoded = $this->utf8_encode_array($response); 146 | 147 | if (function_exists('json_encode') && is_string($json_encoded = json_encode($utf8_encoded))) { 148 | // PHP 5.2+, no utf8 problems 149 | return $json_encoded; 150 | } 151 | 152 | if (is_null($utf8_encoded)) { 153 | return 'null'; 154 | } 155 | if ($utf8_encoded === false) { 156 | return 'false'; 157 | } 158 | if ($utf8_encoded === true) { 159 | return 'true'; 160 | } 161 | if (is_scalar($utf8_encoded)) { 162 | if (is_float($utf8_encoded)) { 163 | return floatval(str_replace(",", ".", strval($utf8_encoded))); 164 | } 165 | 166 | if (is_string($utf8_encoded)) { 167 | static $jsonReplaces = array(array("\\", "/", "\n", "\t", "\r", "\b", "\f", '"'), array('\\\\', '\\/', '\\n', '\\t', '\\r', '\\b', '\\f', '\"')); 168 | return '"' . str_replace($jsonReplaces[0], $jsonReplaces[1], utf8_encode($utf8_encoded)) . '"'; 169 | } else { 170 | return $utf8_encoded; 171 | } 172 | } 173 | $isList = true; 174 | for ($i = 0, reset($utf8_encoded); $i < count($utf8_encoded); $i++, next($utf8_encoded)) { 175 | if (key($utf8_encoded) !== $i) { 176 | $isList = false; 177 | break; 178 | } 179 | } 180 | $result = array(); 181 | if ($isList) { 182 | foreach ($utf8_encoded as $v) { 183 | $result[] = $this->_encode($v); 184 | } 185 | return '[' . join(',', $result) . ']'; 186 | } else { 187 | foreach ($utf8_encoded as $k => $v) { 188 | $result[] = $this->_encode($k) . ':' . $this->_encode($v); 189 | } 190 | return '{' . join(',', $result) . '}'; 191 | } 192 | } 193 | 194 | /** 195 | * Pretty print JSON 196 | * http://www.php.net/manual/en/function.json-encode.php#80339 197 | * 198 | * @param string $json 199 | * 200 | * @return string 201 | */ 202 | public function json_format ($json) { 203 | $new_json = ''; 204 | $indent_level = 0; 205 | $in_string = false; 206 | 207 | $len = strlen($json); 208 | 209 | for ($c = 0; $c < $len; $c++) { 210 | $char = $json[$c]; 211 | switch ($char) { 212 | case '{': 213 | case '[': 214 | if (!$in_string) { 215 | $new_json .= $char . "\n" . str_repeat($this->jsonTab, $indent_level + 1); 216 | $indent_level++; 217 | } else { 218 | $new_json .= $char; 219 | } 220 | break; 221 | case '}': 222 | case ']': 223 | if (!$in_string) { 224 | $indent_level--; 225 | $new_json .= "\n" . str_repeat($this->jsonTab, $indent_level) . $char; 226 | } else { 227 | $new_json .= $char; 228 | } 229 | break; 230 | case ',': 231 | if (!$in_string) { 232 | $new_json .= ",\n" . str_repeat($this->jsonTab, $indent_level); 233 | } else { 234 | $new_json .= $char; 235 | } 236 | break; 237 | case ':': 238 | if (!$in_string) { 239 | $new_json .= ": "; 240 | } else { 241 | $new_json .= $char; 242 | } 243 | break; 244 | case '"': 245 | if ($c > 0 && $json[$c - 1] != '\\') { 246 | $in_string = !$in_string; 247 | } 248 | default: 249 | $new_json .= $char; 250 | break; 251 | } 252 | } 253 | 254 | // Return true json at all cost 255 | if (false === json_decode($new_json)) { 256 | // If we messed up the semantics, return original 257 | return $json; 258 | } 259 | 260 | return $new_json; 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /View/XmlEncodeView.php: -------------------------------------------------------------------------------- 1 | viewVars)) { 12 | trigger_error( 13 | 'viewVar "response" should have been set by Rest component already', 14 | E_USER_ERROR 15 | ); 16 | return false; 17 | } 18 | 19 | return $this->encode($this->viewVars['response']); 20 | } 21 | 22 | public function headers ($Controller, $settings) { 23 | if ($settings['debug'] > 2) { 24 | return null; 25 | } 26 | 27 | header('Content-Type: text/xml'); 28 | $Controller->RequestHandler->respondAs('xml'); 29 | return true; 30 | } 31 | 32 | public function encode ($response) { 33 | require_once dirname(dirname(__FILE__)) . '/Lib/BluntXml.php'; 34 | $this->BluntXml = new BluntXml(); 35 | return $this->BluntXml->encode( 36 | $response, 37 | Inflector::tableize($this->request->params['controller']) . '_response' 38 | ); 39 | } 40 | } 41 | --------------------------------------------------------------------------------