diff --git a/webservice/amf/introspector.php b/webservice/amf/introspector.php new file mode 100644 index 00000000000..6e8bef782dd --- /dev/null +++ b/webservice/amf/introspector.php @@ -0,0 +1,107 @@ +. + * + * @package moodle + * @author Penny Leach + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL + * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com + * + * Introspection for amf - figures out where all the services are and + * returns a list of their available methods. + * Requires $CFG->amf_introspection = true for security. + */ + + +/** + * Provides a function to get details of methods available on another class. + * @author HP + * + */ +class MethodDescriptor { + + private $methods; + private $classes; + + static public $classnametointrospect; + + + public function __construct() { + $this->setup(); + } + + private function setup() { + global $CFG; + if (!empty($this->nothing)) { + return; // we've already tried, no classes. + } + if (!empty($this->classes)) { // we've already done it successfully. + return; + } + /*if (empty($CFG->amf_introspection)) { + throw new Exception(get_string('amfintrospectiondisabled', 'local')); + }*/ + + //just one class here, possibility for expansion in future + $classes = array(MethodDescriptor::$classnametointrospect); + + $hugestructure = array(); + + foreach ($classes as $c) { + $r = new ReflectionClass($c); + + if (!$methods = $r->getMethods()) { + continue; + } + $this->classes[] = $c; + $hugestructure[$c] = array('docs' => $r->getDocComment(), 'methods' => array()); + foreach ($methods as $method) { + if (!$method->isPublic()) { + continue; + } + $params = array(); + foreach ($method->getParameters() as $param) { + $params[] = array('name' => $param->getName(), 'required' => !$param->isOptional()); + } + $hugestructure[$c]['methods'][$method->getName()] = array( + 'docs' => $method->getDocComment(), + 'params' => $params, + ); + } + } + $this->methods = $hugestructure; + if (empty($this->classes)) { + $this->nothing = true; + } + } + + public function getMethods() { + $this->setup(); + return $this->methods; + } + + public function getClasses() { + $this->setup(); + return $this->classes; + } + + public function isConnected() { + return true; + } +} + diff --git a/webservice/amf/locallib.php b/webservice/amf/locallib.php index 73918b3f467..9715a0b35f8 100644 --- a/webservice/amf/locallib.php +++ b/webservice/amf/locallib.php @@ -24,6 +24,21 @@ */ require_once("$CFG->dirroot/webservice/lib.php"); +require_once( "{$CFG->dirroot}/webservice/amf/introspector.php"); + +/** + * Exception indicating an invalid return value from a function. + * Used when an externallib function does not return values of the expected structure. + */ +class invalid_return_value_exception extends moodle_exception { + /** + * Constructor + * @param string $debuginfo some detailed information + */ + function __construct($debuginfo=null) { + parent::__construct('invalidreturnvalue', 'debug', '', null, $debuginfo); + } +} /** * AMF service server implementation. @@ -39,7 +54,108 @@ class webservice_amf_server extends webservice_zend_server { parent::__construct($simple, 'Zend_Amf_Server'); $this->wsname = 'amf'; } + protected function init_service_class(){ + parent::init_service_class(); + //allow access to data about methods available. + $this->zend_server->setClass( "MethodDescriptor" ); + MethodDescriptor::$classnametointrospect = $this->service_class; + } + + protected function service_class_method_body($function, $params){ + $params = "webservice_amf_server::cast_objects_to_array($params)"; + $externallibcall = $function->classname.'::'.$function->methodname.'('.$params.')'; + $descriptionmethod = $function->methodname.'_returns()'; + $callforreturnvaluedesc = $function->classname.'::'.$descriptionmethod; + return +' return webservice_amf_server::validate_and_cast_values('.$callforreturnvaluedesc.', '.$externallibcall.', true)'; + } + /** + * Validates submitted value, comparing it to a description. If anything is incorrect + * invalid_return_value_exception is thrown. Also casts the values to the type specified in + * the description. + * @param external_description $description description of parameters + * @param mixed $value the actual values + * @param boolean $singleasobject specifies whether a external_single_structure should be cast to a stdClass object + * should always be false for use in validating parameters in externallib functions. + * @return mixed params with added defaults for optional items, invalid_parameters_exception thrown if any problem found + */ + public static function validate_and_cast_values(external_description $description, $value) { + if (is_null($description)){ + return $value; + } + if ($description instanceof external_value) { + if (is_array($value) or is_object($value)) { + throw new invalid_return_value_exception('Scalar type expected, array or object received.'); + } + if ($description->type == PARAM_BOOL) { + // special case for PARAM_BOOL - we want true/false instead of the usual 1/0 - we can not be too strict here ;-) + if (is_bool($value) or $value === 0 or $value === 1 or $value === '0' or $value === '1') { + return (bool)$value; + } + } + return validate_param($value, $description->type, $description->allownull, 'Invalid external api parameter'); + + } else if ($description instanceof external_single_structure) { + if (!is_array($value)) { + throw new invalid_return_value_exception('Only arrays accepted.'); + } + $result = array(); + foreach ($description->keys as $key=>$subdesc) { + if (!array_key_exists($key, $value)) { + if ($subdesc->required == VALUE_REQUIRED) { + throw new invalid_return_value_exception('Missing required key in single structure: '.$key); + } + if ($subdesc instanceof external_value) { + if ($subdesc->required == VALUE_DEFAULT) { + $result[$key] = self::validate_and_cast_values($subdesc, $subdesc->default); + } + } + } else { + $result[$key] = self::validate_and_cast_values($subdesc, $value[$key]); + } + unset($value[$key]); + } + if (!empty($value)) { + throw new invalid_return_value_exception('Unexpected keys detected in parameter array.'); + } + return (object)$result; + + } else if ($description instanceof external_multiple_structure) { + if (!is_array($value)) { + throw new invalid_return_value_exception('Only arrays accepted.'); + } + $result = array(); + foreach ($value as $param) { + $result[] = self::validate_and_cast_values($description->content, $param); + } + return $result; + + } else { + throw new invalid_return_value_exception('Invalid external api description.'); + } + } + /** + * Recursive function to recurse down into a complex variable and convert all + * objects to arrays. Doesn't recurse down into objects or cast objects other than stdClass + * which is represented in Flash / Flex as an object. + * @param mixed $params value to cast + * @return mixed Cast value + */ + public static function cast_objects_to_array($params){ + if ($params instanceof stdClass){ + $params = (array)$params; + } + if (is_array($params)){ + $toreturn = array(); + foreach ($params as $key=> $param){ + $toreturn[$key] = self::cast_objects_to_array($param); + } + return $toreturn; + } else { + return $params; + } + } /** * Set up zend service class * @return void @@ -50,6 +166,8 @@ class webservice_amf_server extends webservice_zend_server { //(complete error message displayed into your AMF client) // TODO: add some exception handling } + + } // TODO: implement AMF test client somehow, maybe we could use moodle form to feed the data to the flash app somehow diff --git a/webservice/amf/testclient/AMFConnector.as b/webservice/amf/testclient/AMFConnector.as new file mode 100644 index 00000000000..683ab3bf358 --- /dev/null +++ b/webservice/amf/testclient/AMFConnector.as @@ -0,0 +1,63 @@ +package { + + import flash.events.Event; + import flash.net.NetConnection; + import flash.net.Responder; + + import nl.demonsters.debugger.MonsterDebugger; + + /** + * Wrapper class for the NetConnection/Responder instances + * + * This program is free software. It comes without any warranty, to + * the extent permitted by applicable law. You can redistribute it + * and/or modify it under the terms of the Do What The Fuck You Want + * To Public License, Version 2, as published by Sam Hocevar. See + * http://sam.zoy.org/wtfpl/COPYING for more details. + * + * @author Jordi Boggiano + */ + public class AMFConnector extends NetConnection { + private var responder:Responder; + public var data:Object; + public var error:Boolean = false; + + public function AMFConnector(url:String) { + responder = new Responder(onSuccess, onError); + connect(url); + } + + /** + * executes a command on the remote server, passing all the given arguments along + */ + public function exec(command:String, ... args:Array):void + { + if (!args) args = []; + args.unshift(responder); + args.unshift(command); + (call as Function).apply(this, args); + } + + /** + * handles success + */ + protected function onSuccess(result:Object):void { + MonsterDebugger.trace(this, {'result':result}); + data = result; + dispatchEvent(new Event(Event.COMPLETE)); + data = null; + } + + /** + * handles errors + */ + protected function onError(result:Object):void { + data = result; + MonsterDebugger.trace(this, {'result':result}); + error = true; + dispatchEvent(new Event(Event.COMPLETE)); + error = false; + data = null; + } + } +} \ No newline at end of file diff --git a/webservice/amf/testclient/AMFTester.mxml b/webservice/amf/testclient/AMFTester.mxml new file mode 100644 index 00000000000..ef7eee6591e --- /dev/null +++ b/webservice/amf/testclient/AMFTester.mxml @@ -0,0 +1,396 @@ + + + + + + */ + + import mx.controls.Label; + import mx.controls.Alert; + import mx.messaging.channels.AMFChannel; + import com.adobe.serialization.json.JSON; + +/* // Import the debugger + import nl.demonsters.debugger.MonsterDebugger; + */ + public var api:AMFConnector; + protected var methods:Array; + protected var introspector:String; + + public var rooturl:String; + + [Bindable] + public var argumentToolTip:String = "You can use JSON syntax for method arguments ie. an array is written like this [item1, item2, etc.] objects are written {\"propname\":value, \"propname2\":value2, etc}"; + + // Variable to hold the debugger +// private var debugger:MonsterDebugger; + + /** + * restores the last settings if available + */ + public function init():void + { + // Init the debugger +// debugger = new MonsterDebugger(this); + + // Send a simple trace +// MonsterDebugger.trace(this, "Hello World!"); + + var so:SharedObject = SharedObject.getLocal('AMFTester'); + if (so.data.token) { + token.text = so.data.token; + } + if (so.data.username) { + username.text = so.data.username; + password.text = so.data.password; + } + if (so.data.mode == 'username'){ + loginType.selectedIndex = 1; + } + this.rememberpassword.selected = so.data.rememberpassword; + this.remembertoken.selected = so.data.remembertoken; + this.rooturl = Application.application.parameters.rooturl; + this.urllabel1.text = 'Root URL :'+this.rooturl; + this.urllabel2.text = 'Root URL :'+this.rooturl; + + } + public function doConnectToken():void + { + var url:String = this.rooturl + '/webservice/amf/server.php?'+ + 'wstoken='+this.token.text; + this.doConnect(url); + // saving settings for next time + var so:SharedObject = SharedObject.getLocal('AMFTester'); + if (this.rememberpassword.selected == true ){ + so.setProperty('token', this.token.text); + } else { + so.setProperty('token', null);//delete shared obj prop + } + so.setProperty('remembertoken', this.remembertoken.selected); + so.setProperty('mode', 'token'); + so.flush(); + } + public function doConnectUsername():void + { + var url:String = this.rooturl + '/webservice/amf/simpleserver.php?'+ + 'wsusername=' + this.username.text+ + '&wspassword=' + this.password.text; + this.doConnect(url); + // saving settings for next time + var so:SharedObject = SharedObject.getLocal('AMFTester'); + if (this.rememberpassword.selected == true ){ + so.setProperty('username', this.username.text); + so.setProperty('password', this.password.text); + } else { + so.setProperty('username', null);//delete shared obj prop + so.setProperty('password', null); + } + so.setProperty('rememberpassword', this.rememberpassword.selected); + so.setProperty('mode', 'username'); + so.flush(); + } + + /** + * initializes the connection + */ + private function doConnect(url:String):void + { + api = new AMFConnector(url); + api.exec('MethodDescriptor.getMethods'); + api.addEventListener(Event.COMPLETE, handleConnection); + if (!api.hasEventListener(NetStatusEvent.NET_STATUS)) { + api.addEventListener(NetStatusEvent.NET_STATUS, netStatusHandler); + api.addEventListener(IOErrorEvent.IO_ERROR, ioErrorHandler); + api.addEventListener(SecurityErrorEvent.SECURITY_ERROR, securityErrorHandler); + } + this.panelDebug.enabled = false; + } + + /** + * initializes the debugger dialog with the method list and everything + */ + protected function handleConnection(event:Event):void + { + methods = []; + for (var cls:String in api.data) { + for (var meth:String in api.data[cls]['methods']) { + methods.push({label: cls+'.'+meth, docs: api.data[cls]['methods'][meth]['docs'], args: api.data[cls]['methods'][meth]['params']}); + } + } + + this.panelDebug.enabled = true; + this.maintabs.selectedIndex = 1; + func.dataProvider = methods; + api.removeEventListener(Event.COMPLETE, handleConnection); + api.addEventListener(Event.COMPLETE, process); + reloadArgs(); + + } + + + /** + * outputs a response from the server + */ + protected function process(event:Event):void + { + if (api.error) { + push(input, time() + ": Exception (code: "+api.data.code+", description: "+api.data.description+", detail: "+api.data.detail+", line: "+api.data.line+")\n"); + } else { + push(input, time() + ": "+JSON.encode(api.data)+"\n"); + } +// MonsterDebugger.trace(this, api.data); + } + + /** + * updates the display of arguments when the selected method changes + * + * it's hardly optimal to do it that way but it was faster to copy paste, I just hope nobody needs more than 7 args + */ + protected function reloadArgs():void + { + var i:int; + for (i = 1; i <= 7; i++) { + this['arg'+i].visible = false; + this['arg'+i].includeInLayout = false; + this['larg'+i].visible = false; + this['larg'+i].includeInLayout = false; + this['JSONV'+i].enabled = false; + } + i = 1; + for (var arg:String in func.selectedItem.args) { + (this['arg'+i] as TextInput).visible = true; + (this['arg'+i] as TextInput).includeInLayout = true; + (this['larg'+i] as Label).visible = true; + (this['larg'+i] as Label).includeInLayout = true; + this['JSONV'+i].enabled = true; + this['JSONV'+i].required = func.selectedItem.args[arg]['required']; + + (this['larg'+i++] as Label).text = func.selectedItem.args[arg]['name'] + (func.selectedItem.args[arg]['required'] ? "*":""); + } + if (func.selectedItem.docs == ""){ + (this.methodDescription as TextArea).text = ""; + (this.methodDescription as TextArea).visible = false; + (this.methodDescription as TextArea).includeInLayout = false; + } else { + (this.methodDescription as TextArea).text = func.selectedItem.docs.replace(/[\n\r\f]+/g, "\n"); + (this.methodDescription as TextArea).visible = true; + (this.methodDescription as TextArea).includeInLayout = true; + } + } + + /** + * calls a method on the server + */ + protected function execute():void + { + var input:TextInput; + var argumentArray:Array = []; + var argumentErrors:Array = Validator.validateAll(argumentValidators); + if (argumentErrors.length != 0){ +// MonsterDebugger.trace(this, argumentErrors); + return; + } + for(var i:int = 1; i < 8; i++) + { + input = this['arg' +i] as TextInput; + if(input) + { + if (input.text.indexOf("{") == 0 || input.text.indexOf("[") == 0) + try { + argumentArray.push(JSON.decode(input.text)); + } catch (err:Error){ + return; + } + else + argumentArray.push(input.text as String); + } + } + + + api.exec(func.selectedLabel, argumentArray[0], argumentArray[1], argumentArray[2], argumentArray[3], argumentArray[4], argumentArray[5], argumentArray[6]); +// MonsterDebugger.trace(this, [func.selectedLabel, argumentArray[0], argumentArray[1], argumentArray[2], argumentArray[3], argumentArray[4], argumentArray[5], argumentArray[6]]); + push(output, time() + ": Calling "+func.selectedLabel+" with arguments - "+JSON.encode(argumentArray)); + } + + /** + * clears debug consoles + */ + protected function clear():void + { + input.text = output.text = ""; + } + + /** + * refreshes the method list + */ + protected function refresh():void + { + api.removeEventListener(Event.COMPLETE, process); + api.addEventListener(Event.COMPLETE, handleConnection); + api.exec(introspector); + } + + /** + * returns timestamp string + */ + protected function time():String + { + var d:Date = new Date(); + var ret:String = d.hours+":"+d.minutes+":"+d.seconds+"."+d.milliseconds; + return ret + "000000000000".substring(ret.length); + } + + /** + * handler for specific net events + */ + public function netStatusHandler(event:NetStatusEvent):void + { + push(input, time() + ": Error("+event.type+"): "+event.info.code+", "+event.info.description+", "+event.info.details); + } + + /** + * handler for security errors + */ + public function securityErrorHandler(event:SecurityErrorEvent):void + { + push(input, time() + ": Error("+event.type+"): "+event.text); + } + + /** + * handler for io errors + */ + public function ioErrorHandler(event:IOErrorEvent):void + { + push(input, time() + ": Error("+event.type+"): "+event.text); + } + + /** + * pushes text into a console and scrolls it down automatically + */ + public function push(target:TextArea, text:String):void + { + target.text += text + "\n"; + target.verticalScrollPosition = target.maxVerticalScrollPosition; + } + + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/webservice/amf/testclient/AMFTester.swf b/webservice/amf/testclient/AMFTester.swf new file mode 100644 index 00000000000..8819ff323cc Binary files /dev/null and b/webservice/amf/testclient/AMFTester.swf differ diff --git a/webservice/amf/testclient/customValidators/JSONValidator.as b/webservice/amf/testclient/customValidators/JSONValidator.as new file mode 100644 index 00000000000..39d4982766b --- /dev/null +++ b/webservice/amf/testclient/customValidators/JSONValidator.as @@ -0,0 +1,40 @@ +package customValidators +{ + import com.adobe.serialization.json.JSON; + import com.adobe.serialization.json.JSONParseError; + + import mx.validators.ValidationResult; + import mx.validators.Validator; + + import nl.demonsters.debugger.MonsterDebugger; + + public class JSONValidator extends Validator + { + // Define Array for the return value of doValidation(). + private var errors:Array; + + public function JSONValidator() + { + super(); + } + + override protected function doValidation(value:Object):Array { + var JSONstring:String = String(value); + errors = []; + if (JSONstring != ''){ + try { + JSON.decode(JSONstring); + } catch (err:Error){ + errors.push(new ValidationResult(true, null, "JSON decode failed", + "Not able to decode this JSON.")); + } + } + if (this.required && JSONstring == ''){ + errors.push(new ValidationResult(true, null, "Required", + "You must enter a value for this argument.")); + } + return errors; + } + + } +} \ No newline at end of file diff --git a/webservice/amf/testclient/flashcompilationinstructions.txt b/webservice/amf/testclient/flashcompilationinstructions.txt new file mode 100644 index 00000000000..b82c6eed6cc --- /dev/null +++ b/webservice/amf/testclient/flashcompilationinstructions.txt @@ -0,0 +1,9 @@ +AMFTester.mxml can be compiled as a Flex application in Flex builder or using the Flex SDK. + +Copy the following into a Flex project source folder : + +* customValidators folder and contents +* AMFConnector.as +* AMFTester.mxml + +Then you need to use either the compiled Flex library or the source for the open source library as3corelib available here : http://code.google.com/p/as3corelib/downloads/list diff --git a/webservice/amf/testclient/index.php b/webservice/amf/testclient/index.php index e6c663b963c..918e37833ea 100644 --- a/webservice/amf/testclient/index.php +++ b/webservice/amf/testclient/index.php @@ -1,20 +1,23 @@ wwwroot.'/webservice/amf/testclient/moodleclient.swf'; -$args['width'] = '100%'; -$args['height'] = 500; -$args['majorversion'] = 9; -$args['build'] = 0; -$args['allowscriptaccess'] = 'never'; -$args['quality'] = 'high'; -$args['flashvars'] = 'amfurl='.$CFG->wwwroot.'/webservice/amf/server.php'; -$args['setcontainercss'] = 'true'; +$flashvars = new object(); +$flashvars->rooturl =$CFG->wwwroot; -$PAGE->requires->js('/lib/ufo.js'); -$PAGE->requires->js_function_call('M.util.create_UFO_object', array('moodletestclient', $args)); + +$PAGE->requires->js('/lib/swfobject/swfobject.js', true); + +$PAGE->requires->js_function_call('swfobject.embedSWF', + array($CFG->wwwroot.'/webservice/amf/testclient/AMFTester.swf', //movie + 'moodletestclient', // div id + '100%', // width + '1000', // height + '9.0', // version + false,//no express install swf + $flashvars), //flash vars + true + ); $PAGE->set_title('Test Client'); $PAGE->set_heading('Test Client'); diff --git a/webservice/lib.php b/webservice/lib.php index fd74060c13a..9d29c5ecf2b 100644 --- a/webservice/lib.php +++ b/webservice/lib.php @@ -408,6 +408,8 @@ class '.$classname.' { } $params = implode(', ', $params); $params_desc = implode("\n", $params_desc); + + $serviceclassmethodbody = $this->service_class_method_body($function, $params); if (is_null($function->returns_desc)) { $return = ' * @return void'; @@ -441,12 +443,24 @@ class '.$classname.' { '.$return.' */ public function '.$function->name.'('.$params.') { - return '.$function->classname.'::'.$function->methodname.'('.$params.'); +'.$serviceclassmethodbody.'; } '; return $code; } - + + /** + * You can override this function in your child class to add extra code into the dynamically + * created service class. For example it is used in the amf server to cast types of parameters and to + * cast the return value to the types as specified in the return value description. + * @param unknown_type $function + * @param unknown_type $params + * @return string body of the method for $function ie. everything within the {} of the method declaration. + */ + protected function service_class_method_body($function, $params){ + return ' return '.$function->classname.'::'.$function->methodname.'('.$params.')'; + } + /** * Set up zend service class * @return void