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: * Action params.
98: *
99: * Stored in case debugging is on.
100: * @var array
101: */
102: private $_actionParams;
103:
104: /**
105: * Install error and exception handlers.
106: */
107: public function init()
108: {
109: parent::init();
110:
111: // Install uncaught PHP error handler
112: Yii::app()->attachEventHandler('onError', array($this, 'onError'));
113: // Install uncaught exception handler
114: Yii::app()->attachEventHandler('onException', array($this, 'onException'));
115: }
116:
117: /**
118: * Get request header.
119: *
120: * @param string $fieldName
121: * @return string
122: */
123: protected function getHeader($fieldName)
124: {
125: return Yii::app()->request->getRequestHeader($fieldName);
126: }
127:
128: /**
129: * Print json and headers.
130: *
131: * If we in debug mode, output json is 'prettyfied' for human-readability which
132: * eases debugging.
133: *
134: * @param string $responseObject
135: */
136: protected function renderJson($responseObject)
137: {
138: $responseObject = CBJsonModel::resolveObjectRecursively($responseObject, true);
139:
140: // Fix response content type
141: header('Content-Type: application/json; charset=utf-8;');
142:
143: if ((defined('YII_DEBUG') && (constant('YII_DEBUG') === true))) {
144: // Beautify
145: $responseJson = CBJson::indent(json_encode($responseObject));
146: echo($responseJson);
147: Yii::log($_GET['r']."\n"
148: ."Request: ".print_r($this->_actionParams, true)."\n"
149: ."Response: ".$responseJson, CLogger::LEVEL_TRACE, 'application.RestController');
150: } else {
151: // Simple, compact result
152: echo(json_encode($responseObject));
153: }
154: }
155:
156: /**
157: * Gather mysql logs.
158: * @return string
159: */
160: private function getLogs()
161: {
162: $origLogs = $this->displaySummary(Yii::getLogger()->getLogs('profile', 'system.db.CDbCommand.*'));
163: $finalLogs = array();
164: foreach ($origLogs as &$log) {
165: $finalLogs[] = array(
166: 'sql' => substr($log[0], strpos($log[0], '(') + 1, -1),
167: 'count' => $log[1],
168: 'totalMilli' => sprintf('%.1f', $log[4] * 1000.0),
169: );
170: }
171: return $finalLogs;
172: }
173:
174: public $groupByToken=true;
175:
176: /**
177: * Displays the summary report of the profiling result.
178: * @param array $logs list of logs
179: */
180: protected function displaySummary($logs)
181: {
182: $stack=array();
183: foreach($logs as $log)
184: {
185: if($log[1]!==CLogger::LEVEL_PROFILE)
186: continue;
187: $message=$log[0];
188: if(!strncasecmp($message,'begin:',6))
189: {
190: $log[0]=substr($message,6);
191: $stack[]=$log;
192: }
193: else if(!strncasecmp($message,'end:',4))
194: {
195: $token=substr($message,4);
196: if(($last=array_pop($stack))!==null && $last[0]===$token)
197: {
198: $delta=$log[3]-$last[3];
199: if(!$this->groupByToken)
200: $token=$log[2];
201: if(isset($results[$token]))
202: $results[$token]=$this->aggregateResult($results[$token],$delta);
203: else
204: $results[$token]=array($token,1,$delta,$delta,$delta);
205: }
206: else
207: 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.',
208: array('{token}'=>$token)));
209: }
210: }
211:
212: $now=microtime(true);
213: while(($last=array_pop($stack))!==null)
214: {
215: $delta=$now-$last[3];
216: $token=$this->groupByToken ? $last[0] : $last[2];
217: if(isset($results[$token]))
218: $results[$token]=$this->aggregateResult($results[$token],$delta);
219: else
220: $results[$token]=array($token,1,$delta,$delta,$delta);
221: }
222:
223: $entries=array_values($results);
224: $func=create_function('$a,$b','return $a[4]<$b[4]?1:0;');
225: usort($entries,$func);
226:
227: return $entries;
228: }
229:
230: /**
231: * Aggregates the report result.
232: * @param array $result log result for this code block
233: * @param float $delta time spent for this code block
234: * @return array
235: */
236: protected function aggregateResult($result,$delta)
237: {
238: list($token,$calls,$min,$max,$total)=$result;
239: if($delta<$min)
240: $min=$delta;
241: else if($delta>$max)
242: $max=$delta;
243: $calls++;
244: $total+=$delta;
245: return array($token,$calls,$min,$max,$total);
246: }
247:
248: /**
249: * Runs the action after passing through all filters.
250: *
251: * This method is invoked by {@link runActionWithFilters} after all possible filters have been
252: * executed and the action starts to run.
253: *
254: * The major difference from the parent method is that it does the rendering
255: * instead of the actions themselves which just return objects.
256: *
257: * Also catches exceptions and prints them accordingly.
258: *
259: * @param CAction $action action to run
260: */
261: public function runAction($action)
262: {
263: // Retrieve action parameters
264: $this->_actionParams = $this->getActionParams();
265:
266: if (!$this->beforeAction($action)) {
267: // Validate request
268: throw new CHttpException(403, 'Restful action execution forbidden.');
269: }
270:
271: // Run action and get response
272: $responseObject = $action->runWithParams($this->_actionParams);
273:
274: // Run post-action code
275: $this->afterAction($action);
276:
277: // Render action response object
278: $this->renderJson($responseObject);
279: }
280:
281: /**
282: * Creates the action instance based on the action name.
283: *
284: * The method differs from the parent in that it uses CBJsonInlineAction for inline actions.
285: *
286: * @param string $actionId ID of the action. If empty, the {@link defaultAction default action} will be used.
287: * @return CAction the action instance, null if the action does not exist.
288: * @see actions
289: * @todo Implement External Actions as well.
290: */
291: public function createAction($actionId)
292: {
293: if ($actionId === '') {
294: $actionId = $this->defaultAction;
295: }
296:
297: if (method_exists($this, 'action'.$actionId) && strcasecmp($actionId, 's')) { // we have actions method
298: return new CBJsonInlineAction($this, $actionId);
299: } else {
300: $action = $this->createActionFromMap($this->actions(), $actionId, $actionId);
301: if ($action !== null && !method_exists($action, 'run'))
302: throw new CException(Yii::t('yii',
303: 'Action class {class} must implement the "run" method.',
304: array('{class}' => get_class($action))));
305: return $action;
306: }
307: }
308:
309: /**
310: * Extract json input object.
311: *
312: * Jsin is short for "json input object". The jsin can be extracted in two ways:
313: * <ol>
314: * <li>From raw PUT/POST request body, if it's a PUT/POST request</li>
315: * <li>From 'jsin' GET parameter, if it's a GET request and we're in test mode</li>
316: * </ol>
317: *
318: * @return array
319: */
320: public function getActionParams()
321: {
322: // Get handly pointer
323: $request = Yii::app()->request;
324:
325: switch ($request->getRequestType()) {
326: case 'PUT':
327: case 'POST':
328:
329: if (!empty($_POST)) {
330: $params = $_REQUEST;
331: } else if ($request->getIsJsonRequest()) {
332: // Read js input object as a string first
333: $params = array(
334: 'jsin' => $request->getRequestRawBody()
335: );
336: } else {
337: $params = array();
338: }
339: break;
340:
341: default:
342: $params = $_GET;
343: break;
344: }
345:
346: return $params;
347: }
348:
349: /**
350: * Handle uncaught exception.
351: *
352: * @param CExceptionEvent $event
353: */
354: public function onException($event)
355: {
356: $e = $event->exception;
357:
358: // Directly return an exception
359: $this->renderJson($this->createErrorObject($e->getCode(), $e->getMessage(), $e->getTraceAsString(), get_class($e)));
360:
361: // Don't bubble up
362: $event->handled = true;
363: }
364:
365: /**
366: * Handle uncaught PHP notice/warning/error.
367: *
368: * @param CErrorEvent $event
369: */
370: public function onError($event)
371: {
372: //
373: // Extract backtrace
374: //
375: $trace=debug_backtrace();
376: // skip the first 4 stacks as they do not tell the error position
377: if(count($trace)>4)
378: $trace=array_slice($trace,4);
379:
380: $traceString = "#0 ".$event->file."(".$event->line."): ";
381: foreach($trace as $i=>$t)
382: {
383: if ($i !== 0) {
384: if(!isset($t['file']))
385: $trace[$i]['file']='unknown';
386:
387: if(!isset($t['line']))
388: $trace[$i]['line']=0;
389:
390: if(!isset($t['function']))
391: $trace[$i]['function']='unknown';
392:
393: $traceString.="\n#$i {$trace[$i]['file']}({$trace[$i]['line']}): ";
394: }
395: if(isset($t['object']) && is_object($t['object']))
396: $traceString.=get_class($t['object']).'->';
397: $traceString.="{$trace[$i]['function']}()";
398:
399: unset($trace[$i]['object']);
400: }
401:
402: //
403: // Directly return an exception
404: //
405: $this->renderJson($this->createErrorObject($event->code, $event->message, $traceString, 'PHP Error'));
406:
407: // Don't bubble up
408: $event->handled = true;
409: }
410:
411: /**
412: * Output total millitime header.
413: */
414: protected function outputTotalMillitimeHeader()
415: {
416: // Total execution time in milliseconds
417: header('Total-Millitime: '.sprintf('%.1f', 1000.0 * Yii::getLogger()->executionTime));
418: }
419:
420: /**
421: * Create a standard-form error object from passed details.
422: *
423: * This allows for all kinds of errors (exceptions, php errors, etc.) to be returned to
424: * the service user in a standard form.
425: *
426: * If you wish to add further notification mechanisms you can override this method.
427: *
428: * @param integer $code
429: * @param string $message
430: * @param string $traceString
431: * @param string $type
432: * @return array
433: */
434: protected function createErrorObject($code, $message, $traceString, $type)
435: {
436: $errorObject = array(
437: 'message' => $message,
438: 'code' => $code,
439: 'type' => $type,
440: );
441:
442: if ((defined('YII_DEBUG') && (constant('YII_DEBUG') === true))) {
443: $errorObject['trace'] = explode("\n", $traceString);
444: }
445:
446: return $errorObject;
447: }
448: }
449: