1: <?php
2: /**
3: * Yii controllers exchanging JSON objects.
4: *
5: * This base controller class allows developers to write controller actions which expect
6: * object parameters and return objects instead of printing/rendering them. It also has a
7: * uniform error handling mechanism which converts exceptions and PHP errors to standard-form
8: * jsons. The client application can be tuned to properly handle such exceptions.
9: *
10: * This approach, apart from making writing actions as if they were naturally called by the
11: * client/consumer, allows for proper out-of-the-box documentation (i.e. through apigen).
12: *
13: * <p>
14: * <h2>Action signatures</h2>
15: * All restful action <b>signatures</b>:
16: * <ul>
17: * <li>Accept at most one <b>input parameter</b> object. The input parameter name can be
18: * anything since there's at most one parameter.</li>
19: * <li>Return exactly one <b>output</b> object.</li>
20: * </ul>
21: * Here is an example of a restful action expecting an object and returning another:
22: * <pre>
23: * public function actionGetAll(NotificationQueryJson $notificationQuery)
24: * {
25: * return new NotificationJson();
26: * }
27: * </pre>
28: *
29: * <p>
30: * <h2>Request/Input Objects</h2>
31: * <p>
32: * <b>Input</b> parameters are fetched in two different ways:
33: * <ul>
34: * <li><b>Production mode</b>: From the POST body as raw json object.</li>
35: * <li><b>Development mode</b>: From the 'jsin' GET parameter as raw json object.</li>
36: * </ul>
37: *
38: * <p>
39: * <b>Input</b> object types of a restful action can be:
40: * <ul>
41: * <li>Scalars</li>
42: * <li>Objects of a CBJsonModel subtype</li>
43: * </ul>
44: *
45: *
46: * <p>
47: * <h2>Request/Input Headers</h2>
48: * <p>
49: * Further input can be passed to a restful action through <b>request headers</b>:
50: * <ul>
51: * <li><b>Production mode</b>: Headers of the form 'this-is-some-header' are accessed as
52: * 'thisIsSomeHeader':
53: * <pre>
54: * this-is-some-header: someValue\r\n
55: * this-is-another-header: anotherValue\r\n
56: * </pre>
57: * </li>
58: * <li><b>Development mode</b>: From the 'header' GET parameter as a hash:
59: * <pre>
60: * http://.../jsin=...&header[thisIsSomeHeader]=someValue&header[thisIsAnotherHeader]=anotherValue
61: * </pre>
62: * </li>
63: * </ul>
64: *
65: * <p>
66: * <h2>Response/Output objects</h2>
67: * <p>
68: * <b>Output</b> object types of a restful action can be:
69: * <ul>
70: * <li>Scalars</li>
71: * <li>Objects of a CBJsonModel subtype</li>
72: * <li>Arrays of the above two element types</li>
73: * </ul>
74: * Output objects are always automatically converted to json. Appropriate content-type headers
75: * are also automatically sent.
76: *
77: * <p>
78: * <h2>Response/Output errors</h2>
79: * <p>
80: * <b>Errors</b> are uniformly output using the createErrorObject method.
81: *
82: * <p>
83: * <h2>Examples</h2>
84: * <p>
85: * Here is an example GET request (for development mode) with both header and json:
86: * <pre>
87: * http://.../index-test.php?r=controller/action&header[applicationId]=1&jsin={"id":34034,"name":"John Doe"}
88: * </pre>
89: *
90: * @since 1.0
91: * @package Components
92: * @author Konstantinos Filios <konfilios@gmail.com>
93: */
94: class CBJsonController extends CController
95: {
96: /**
97: * Active action.
98: * @var CAction
99: */
100: private $_action;
101:
102: /**
103: * Action params.
104: *
105: * Stored in case debugging is on.
106: * @var array
107: */
108: private $_actionParams;
109:
110: /**
111: * Install error and exception handlers.
112: */
113: public function init()
114: {
115: parent::init();
116:
117: // Install uncaught PHP error handler
118: Yii::app()->attachEventHandler('onError', array($this, 'onError'));
119: // Install uncaught exception handler
120: Yii::app()->attachEventHandler('onException', array($this, 'onException'));
121: }
122:
123: /**
124: * Get request header.
125: *
126: * @param string $fieldName
127: * @return string
128: */
129: protected function getHeader($fieldName)
130: {
131: return Yii::app()->request->getRequestHeader($fieldName);
132: }
133:
134: /**
135: * Print json and headers.
136: *
137: * If we in debug mode, output json is 'prettyfied' for human-readability which
138: * eases debugging.
139: *
140: * @param string $responseObject
141: */
142: protected function renderJson($responseObject)
143: {
144: $responseObject = CBJsonModel::resolveObjectRecursively($responseObject, true);
145:
146: // Fix response content type
147: header('Content-Type: application/json; charset=utf-8;');
148:
149: if ((defined('YII_DEBUG') && (constant('YII_DEBUG') === true))) {
150: // Beautify
151: $responseJson = CBJson::indent(json_encode($responseObject));
152: echo($responseJson);
153: Yii::log((!empty($this->_action) ? $this->_action->id : 'Unknown action')."\n"
154: ."Request Params: ".print_r($this->_actionParams, true)."\n"
155: ."Response Object: ".$responseJson, CLogger::LEVEL_TRACE, 'application.CBRestController');
156: } else {
157: // Simple, compact result
158: echo(json_encode($responseObject));
159: }
160: }
161:
162: /**
163: * Gather mysql logs.
164: * @return string
165: */
166: private function getLogs()
167: {
168: $origLogs = $this->displaySummary(Yii::getLogger()->getLogs('profile', 'system.db.CDbCommand.*'));
169: $finalLogs = array();
170: foreach ($origLogs as &$log) {
171: $finalLogs[] = array(
172: 'sql' => substr($log[0], strpos($log[0], '(') + 1, -1),
173: 'count' => $log[1],
174: 'totalMilli' => sprintf('%.1f', $log[4] * 1000.0),
175: );
176: }
177: return $finalLogs;
178: }
179:
180: public $groupByToken=true;
181:
182: /**
183: * Displays the summary report of the profiling result.
184: * @param array $logs list of logs
185: */
186: protected function displaySummary($logs)
187: {
188: $stack=array();
189: foreach($logs as $log)
190: {
191: if($log[1]!==CLogger::LEVEL_PROFILE)
192: continue;
193: $message=$log[0];
194: if(!strncasecmp($message,'begin:',6))
195: {
196: $log[0]=substr($message,6);
197: $stack[]=$log;
198: }
199: else if(!strncasecmp($message,'end:',4))
200: {
201: $token=substr($message,4);
202: if(($last=array_pop($stack))!==null && $last[0]===$token)
203: {
204: $delta=$log[3]-$last[3];
205: if(!$this->groupByToken)
206: $token=$log[2];
207: if(isset($results[$token]))
208: $results[$token]=$this->aggregateResult($results[$token],$delta);
209: else
210: $results[$token]=array($token,1,$delta,$delta,$delta);
211: }
212: else
213: throw new CException(Yii::t('yii','CProfileLogRoute found a mismatching code block "{token}". Make sure the calls to Yii::beginProfile() and Yii::endProfile() be properly nested.',
214: array('{token}'=>$token)));
215: }
216: }
217:
218: $now=microtime(true);
219: while(($last=array_pop($stack))!==null)
220: {
221: $delta=$now-$last[3];
222: $token=$this->groupByToken ? $last[0] : $last[2];
223: if(isset($results[$token]))
224: $results[$token]=$this->aggregateResult($results[$token],$delta);
225: else
226: $results[$token]=array($token,1,$delta,$delta,$delta);
227: }
228:
229: $entries=array_values($results);
230: $func=create_function('$a,$b','return $a[4]<$b[4]?1:0;');
231: usort($entries,$func);
232:
233: return $entries;
234: }
235:
236: /**
237: * Aggregates the report result.
238: * @param array $result log result for this code block
239: * @param float $delta time spent for this code block
240: * @return array
241: */
242: protected function aggregateResult($result,$delta)
243: {
244: list($token,$calls,$min,$max,$total)=$result;
245: if($delta<$min)
246: $min=$delta;
247: else if($delta>$max)
248: $max=$delta;
249: $calls++;
250: $total+=$delta;
251: return array($token,$calls,$min,$max,$total);
252: }
253:
254: /**
255: * Runs the action after passing through all filters.
256: *
257: * This method is invoked by {@link runActionWithFilters} after all possible filters have been
258: * executed and the action starts to run.
259: *
260: * The major difference from the parent method is that it does the rendering
261: * instead of the actions themselves which just return objects.
262: *
263: * Also catches exceptions and prints them accordingly.
264: *
265: * @param CAction $action action to run
266: */
267: public function runAction($action)
268: {
269: // Store action for debugging
270: $this->_action = $action;
271:
272: // Retrieve action parameters
273: $this->_actionParams = $this->getActionParams();
274:
275: if (!$this->beforeAction($action)) {
276: // Validate request
277: throw new CHttpException(403, 'Restful action execution forbidden.');
278: }
279:
280: // Run action and get response
281: $responseObject = $action->runWithParams($this->_actionParams);
282:
283: // Run post-action code
284: $this->afterAction($action);
285:
286: // Render action response object
287: $this->renderJson($responseObject);
288: }
289:
290: /**
291: * Creates the action instance based on the action name.
292: *
293: * The method differs from the parent in that it uses CBJsonInlineAction for inline actions.
294: *
295: * @param string $actionId ID of the action. If empty, the {@link defaultAction default action} will be used.
296: * @return CAction the action instance, null if the action does not exist.
297: * @see actions
298: * @todo Implement External Actions as well.
299: */
300: public function createAction($actionId)
301: {
302: if ($actionId === '') {
303: $actionId = $this->defaultAction;
304: }
305:
306: if (method_exists($this, 'action'.$actionId) && strcasecmp($actionId, 's')) { // we have actions method
307: return new CBJsonInlineAction($this, $actionId);
308: } else {
309: $action = $this->createActionFromMap($this->actions(), $actionId, $actionId);
310: if ($action !== null && !method_exists($action, 'run'))
311: throw new CException(Yii::t('yii',
312: 'Action class {class} must implement the "run" method.',
313: array('{class}' => get_class($action))));
314: return $action;
315: }
316: }
317:
318: /**
319: * Extract json input object.
320: *
321: * Jsin is short for "json input object". The jsin can be extracted in two ways:
322: * <ol>
323: * <li>From raw PUT/POST request body, if it's a PUT/POST request</li>
324: * <li>From 'jsin' GET parameter, if it's a GET request and we're in test mode</li>
325: * </ol>
326: *
327: * @return array
328: */
329: public function getActionParams()
330: {
331: // Get handly pointer
332: $request = Yii::app()->request;
333:
334: switch ($request->getRequestType()) {
335: case 'PUT':
336: case 'POST':
337:
338: if (!empty($_POST)) {
339: $params = $_REQUEST;
340: } else if ($request->getIsJsonRequest()) {
341: // Read js input object as a string first
342: $params = array(
343: 'jsin' => $request->getRequestRawBody()
344: );
345: } else {
346: $params = array();
347: }
348: break;
349:
350: default:
351: $params = $_GET;
352: break;
353: }
354:
355: return $params;
356: }
357:
358: /**
359: * Handle uncaught exception.
360: *
361: * @param CExceptionEvent $event
362: */
363: public function onException($event)
364: {
365: $e = $event->exception;
366:
367: // Directly return an exception
368: $this->renderJson($this->createErrorObject($e->getCode(), $e->getMessage(), $e->getTraceAsString(), get_class($e)));
369:
370: // Don't bubble up
371: $event->handled = true;
372: }
373:
374: /**
375: * Handle uncaught PHP notice/warning/error.
376: *
377: * @param CErrorEvent $event
378: */
379: public function onError($event)
380: {
381: //
382: // Extract backtrace
383: //
384: $trace=debug_backtrace();
385: // skip the first 4 stacks as they do not tell the error position
386: if(count($trace)>4)
387: $trace=array_slice($trace,4);
388:
389: $traceString = "#0 ".$event->file."(".$event->line."): ";
390: foreach($trace as $i=>$t)
391: {
392: if ($i !== 0) {
393: if(!isset($t['file']))
394: $trace[$i]['file']='unknown';
395:
396: if(!isset($t['line']))
397: $trace[$i]['line']=0;
398:
399: if(!isset($t['function']))
400: $trace[$i]['function']='unknown';
401:
402: $traceString.="\n#$i {$trace[$i]['file']}({$trace[$i]['line']}): ";
403: }
404: if(isset($t['object']) && is_object($t['object']))
405: $traceString.=get_class($t['object']).'->';
406: $traceString.="{$trace[$i]['function']}()";
407:
408: unset($trace[$i]['object']);
409: }
410:
411: //
412: // Directly return an exception
413: //
414: $this->renderJson($this->createErrorObject($event->code, $event->message, $traceString, 'PHP Error'));
415:
416: // Don't bubble up
417: $event->handled = true;
418: }
419:
420: /**
421: * Output total millitime header.
422: */
423: protected function outputTotalMillitimeHeader()
424: {
425: // Total execution time in milliseconds
426: header('Total-Millitime: '.sprintf('%.1f', 1000.0 * Yii::getLogger()->executionTime));
427: }
428:
429: /**
430: * Create a standard-form error object from passed details.
431: *
432: * This allows for all kinds of errors (exceptions, php errors, etc.) to be returned to
433: * the service user in a standard form.
434: *
435: * If you wish to add further notification mechanisms you can override this method.
436: *
437: * @param integer $code
438: * @param string $message
439: * @param string $traceString
440: * @param string $type
441: * @return array
442: */
443: protected function createErrorObject($code, $message, $traceString, $type)
444: {
445: $errorObject = array(
446: 'message' => $message,
447: 'code' => $code,
448: 'type' => $type,
449: );
450:
451: if ((defined('YII_DEBUG') && (constant('YII_DEBUG') === true))) {
452: $errorObject['trace'] = explode("\n", $traceString);
453: }
454:
455: return $errorObject;
456: }
457: }
458: