<?php
/**
 * Ripcord is an easy to use XML-RPC library for PHP. 
 * @package Ripcord
 * @author Auke van Slooten <auke@muze.nl>
 * @copyright Copyright (C) 2010, Muze <www.muze.nl>
 * @license http://opensource.org/licenses/gpl-3.0.html GNU Public License
 * @version Ripcord 0.9 - PHP 5
 */
 
/**
 * Includes the static ripcord factory class and exceptions
 */
require_once(dirname(__FILE__).'/ripcord.php');

/**
 * This class implements a simple RPC client, for XML-RPC, (simplified) SOAP 1.1 or Simple RPC. The client abstracts 
 * the entire RPC process behind native PHP methods. Any method defined by the rpc server can be called as if it was
 * a native method of the rpc client.
 * 
 *  E.g.
 *  <code>
 *  <?php
 *    $client = ripcord::client( 'http://www.moviemeter.nl/ws' );
 *    $score = $client->film->getScore( 'e3dee9d19a8c3af7c92f9067d2945b59', 500 );
 *  ?>
 *  </code>
 * 
 * The client has a simple interface for the system.multiCall method:  
 * <code>
 * <?php
 *  $client = ripcord::client( 'http://ripcord.muze.nl/ripcord.php' );
 *  $client->system->multiCall()->start();
 *  ripcord::bind( $methods, $client->system->listMethods() );
 *  ripcord::bind( $foo, $client->getFoo() );
 *  $client->system->multiCall()->execute();
 * ?>
 * </code>
 * 
 * The soap client can only handle the basic php types and doesn't understand xml namespaces. Use PHP's SoapClient 
 * for complex soap calls. This client cannot parse wsdl.
 * If you want to skip the ripcord::client factory method, you _must_ provide a transport object explicitly.
 *
 * @link  http://wiki.moviemeter.nl/index.php/API Moviemeter API documentation
 * @package Ripcord
 */
class Ripcord_Client 
{
	/**
	 * The url of the rpc server
	 */
	private $_url = '';

	/**
	 * The transport object, used to post requests.
	 */
	private $_transport = null;

	/**
	 * A list of output options, used with the xmlrpc_encode_request method.
	 * @see Ripcord_Server::setOutputOption()
	 */
	private $_outputOptions = array(
		"output_type" => "xml",
		"verbosity" => "pretty",
		"escaping" => array("markup"),
		"version" => "xmlrpc",
		"encoding" => "utf-8"
	);

	/**
	 * The namespace to use when calling a method.
	 */
	private $_namespace = null;

	/**
	 * A reference to the root client object. This is so when you use namespaced sub clients, you can always
	 * find the _response and _request data in the root client.
	 */
	private $_rootClient = null;
	
	/**
	 * A flag to indicate whether or not to preemptively clone objects passed as arguments to methods, see
	 * php bug #50282. Only correctly set in the rootClient.
	 */
	private $_cloneObjects = false;
	
	/**
	 * A flag to indicate if we are in a multiCall block. Start this with $client->system->multiCall()->start()
	 */
	protected $_multiCall = false;

	/**
	 * A list of deferred encoded calls.
	 */
	protected $_multiCallArgs = array();
	
	/**
	  * The exact response from the rpc server. For debugging purposes.
	 */
	public $_response = '';

	/**
	 * The exact request from the client. For debugging purposes.
	 */
	public $_request = '';

	/**
	 * Whether or not to throw exceptions when an xml-rpc fault is returned by the server. Default is false.
	 */
	public $_throwExceptions = false;
	
	/**
	 * Whether or not to decode the XML-RPC datetime and base64 types to unix timestamp and binary string
	 * respectively.
	 */
	public $_autoDecode = true;
	
	/**
	 * The constructor for the RPC client.
	 * @param string $url The url of the rpc server
	 * @param array $options Optional. A list of outputOptions. See {@link Ripcord_Server::setOutputOption()}
	 * @param object $rootClient Optional. Used internally when using namespaces.
	 * @throws Ripcord_ConfigurationException (ripcord::xmlrpcNotInstalled) when the xmlrpc extension is not available.
	 */
	public function __construct( $url, array $options = null, $transport = null, $rootClient = null ) 
	{
		if ( !isset($rootClient) ) {
			$rootClient = $this;
			if ( !function_exists( 'xmlrpc_encode_request' ) )
			{
				throw new Ripcord_ConfigurationException('PHP XMLRPC library is not installed', 
					ripcord::xmlrpcNotInstalled);
			}
			$version = explode('.', phpversion() );
			if ( (0 + $version[0]) == 5) {
				if ( ( 0 + $version[1]) < 2 ) { 
					$this->_cloneObjects = true; // workaround for bug #50282
				}
			}
		}
		$this->_rootClient = $rootClient;
		$this->_url = $url;
		if ( isset($options) ) 
		{
			if ( isset($options['namespace']) ) 
			{
				$this->_namespace = $options['namespace'];
				unset( $options['namespace'] );
			}
			$this->_outputOptions = $options;
		}
		if ( isset($transport) ) {
			$this->_transport = $transport;
		}
	}

	/**
	 * This method catches any native method called on the client and calls it on the rpc server instead. It automatically
	 * parses the resulting xml and returns native php type results.
	 * @throws Ripcord_InvalidArgumentException (ripcord::notRipcordCall) when handling a multiCall and the 
	 * arguments passed do not have the correct method call information
	 * @throws Ripcord_RemoteException when _throwExceptions is true and the server returns an XML-RPC Fault.
	 */
	public function __call($name, $args) 
	{
		if ( isset($this->_namespace) ) 
		{
			$name = $this->_namespace . '.' . $name;
		}

		if ( $name === 'system.multiCall' || $name == 'system.multicall' ) 
		{
			if ( !$args || ( is_array($args) && count($args)==0 ) ) 
			{
				// multiCall is called without arguments, so return the fetch interface object
				return new Ripcord_Client_MultiCall( $this->_rootClient, $name );
			} else if ( is_array( $args ) && (count( $args ) == 1) && 
				is_array( $args[0] )  && !isset( $args[0]['methodName'] ) ) 
			{ 
				// multicall is called with a simple array of calls.
				$args = $args[0];
			}
			$this->_rootClient->_multiCall = false;
			$params = array();
			$bound = array();
			foreach ( $args as $key => $arg ) 
			{
				if ( !is_a( $arg, 'Ripcord_Client_Call' ) && 
					(!is_array($arg) || !isset($arg['methodName']) ) ) 
				{
					throw new Ripcord_InvalidArgumentException(
						'Argument '.$key.' is not a valid Ripcord call', 
							ripcord::notRipcordCall);
				}
				if ( is_a( $arg, 'Ripcord_Client_Call' ) ) 
				{
					$arg->index  = count( $params );
					$params[]    = $arg->encode();
				}
				else
				{
					$arg['index'] = count( $params );
					$params[]    = array(
						'methodName' => $arg['methodName'],
						'params'     => isset($arg['params']) ? 
							(array) $arg['params'] : array()
					);
				}
				$bound[$key] = $arg;
			}
			$args = array( $params );
			$this->_rootClient->_multiCallArgs = array();
		}
		if ( $this->_rootClient->_multiCall ) {
			$call = new Ripcord_Client_Call( $name, $args );
			$this->_rootClient->_multiCallArgs[] = $call;
			return $call;
		}
		if ($this->_rootClient->_cloneObjects) { //workaround for php bug 50282
			foreach( $args as $key => $arg) {
				if (is_object($arg)) {
					$args[$key] = clone $arg;
				}
			}
		}
		$request  = xmlrpc_encode_request( $name, $args, $this->_outputOptions );
		$response = $this->_transport->post( $this->_url, $request );
		$result   = xmlrpc_decode( $response, $this->_outputOptions['encoding'] );
		$this->_rootClient->_request  = $request;
		$this->_rootClient->_response = $response;
		if ( ripcord::isFault( $result ) && $this->_throwExceptions ) 
		{
			throw new Ripcord_RemoteException($result['faultString'], $result['faultCode']);
		}
		if ( isset($bound) && is_array( $bound ) ) 
		{
			foreach ( $bound as $key => $callObject ) 
			{
				if ( is_a( $callObject, 'Ripcord_Client_Call' ) )
				{
					$returnValue = $result[$callObject->index];
				}
				else
				{
					$returnValue = $result[$callObject['index']];
				}
				if ( is_array( $returnValue ) && count( $returnValue ) == 1 ) 
				{
					// XML-RPC specification says that non-fault results must be in a single item array
					$returnValue = current($returnValue);
				}
				if ($this->_autoDecode)
				{
					$type = xmlrpc_get_type($returnValue);
					switch ($type) 
					{
						case 'base64' : 
							$returnValue = ripcord::binary($returnValue);
						break;
						case 'datetime' :
							$returnValue = ripcord::timestamp($returnValue);
						break;
					}
				}
				if ( is_a( $callObject, 'Ripcord_Client_Call' ) ) {
					$callObject->bound = $returnValue;
				} 
				$bound[$key] = $returnValue;
			}
			$result = $bound;
		}		
		return $result;
	}

	/**
	 * This method catches any reference to properties of the client and uses them as a namespace. The
	 * property is automatically created as a new instance of the rpc client, with the name of the property
	 * as a namespace.
	 * @param string $name The name of the namespace
	 * @return object A Ripcord Client with the given namespace set.
	 */
	public function __get($name) 
	{
		$result = null;
		if ( !isset($this->{$name}) ) 
		{
			$result = new Ripcord_Client(
				$this->_url, 
				array_merge($this->_outputOptions, array( 
					'namespace' => $this->_namespace ? 
						$this->_namespace . '.' . $name : $name
				) ),
				$this->_transport,
				$this->_rootClient
			);
			$this->{$name} = $result;
		}
		return $result;
	}
}

/**
 * This class provides the fetch interface for system.multiCall. It is returned
 * when calling $client->system->multiCall() with no arguments. Upon construction
 * it puts the originating client into multiCall deferred mode. The client will
 * gather the requested method calls instead of executing them immediately. It
 * will them execute all of them, in order, when calling
 * $client->system->multiCall()->fetch().
 * This class extends Ripcord_Client only so it has access to its protected _multiCall
 * property.
 */
class Ripcord_Client_MultiCall extends Ripcord_Client 
{
	
	/*
	 * The reference to the originating client to put into multiCall mode.
	 */
	private $client = null;
	
	/*
	 * This method creates a new multiCall fetch api object.
	 */
	public function __construct( $client, $methodName = 'system.multiCall' ) 
	{
		$this->client = $client;
		$this->methodName = $methodName;
	}

	/*
	 * This method puts the client into multiCall mode. While in this mode all 
	 * method calls are collected as deferred calls (Ripcord_Client_Call).
	 */
	public function start() 
	{	
		$this->client->_multiCall = true;
	}
	
	/*
	 * This method finally calls the clients multiCall method with all deferred
	 * method calls since multiCall mode was enabled.
	 */
	public function execute() 
	{
		if ($this->methodName=='system.multiCall') {
			return $this->client->system->multiCall( $this->client->_multiCallArgs );
		} else { // system.multicall
			return $this->client->system->multicall( $this->client->_multiCallArgs );
		}
	}
	
}

/**
 *  This class is used with the Ripcord_Client when calling system.multiCall. Instead of immediately calling the method on the rpc server,
 *  a Ripcord_Client_Call  object is created with all the information needed to call the method using the multicall parameters. The call object is
 *  returned immediately and is used as input parameter for the multiCall call. The result of the call can be bound to a php variable. This
 *  variable will be filled with the result of the call when it is available.
 * @package Ripcord
 */
class Ripcord_Client_Call 
{
	/**
	 * The method to call on the rpc server
	 */
	public $method = null;
	
	/**
	 * The arguments to pass on to the method.
	 */
	public $params = array();
	
	/**
	 * The index in the multicall request array, if any.
	 */
	public $index  = null;
	
	/**
	 * A reference to the php variable to fill with the result of the call, if any.
	 */
	public $bound  = null;
	
	/**
	 * The constructor for the Ripcord_Client_Call class.
	 * @param string $method The name of the rpc method to call
	 * @param array $params The parameters for the rpc method.
	 */
	public function __construct($method, $params) 
	{
		$this->method = $method;
		$this->params = $params;
	}

	/**
	 * This method allows you to bind a php variable to the result of this method call.
	 * When the method call's result is available, the php variable will be filled with
	 * this result.
	 * @param mixed $bound The variable to bind the result from this call to.
	 * @return object Returns this object for chaining.
	 */
	public function bind(&$bound) 
	{
		$this->bound =& $bound;
		return $this;
	}

	/**
	 * This method returns the correct format for a multiCall argument.
	 * @return array An array with the methodName and params
	 */
	public function encode() {
		return array(
			'methodName' => $this->method,
			'params' => (array) $this->params
		);
	}
	
}

/**
 * This interface describes the minimum interface needed for the transport object used by the
 * Ripcord_Client
 * @package Ripcord
 */
interface Ripcord_Transport 
{
	/**
	 * This method must post the request to the given url and return the results.
	 * @param string $url The url to post to.
	 * @param string $request The request to post.
	 * @return string The server response
	 */
	public function post( $url, $request );
}

/**
 * This class implements the Ripcord_Transport interface using PHP streams.
 * @package Ripcord
 */
class  Ripcord_Transport_Stream implements Ripcord_Transport 
{
	/**
	 * A list of stream context options.
	 */
	private $options = array();
	
	/**
	 * Contains the headers sent by the server.
	 */
	public $responseHeaders = null;
	
	/**
	 * This is the constructor for the Ripcord_Transport_Stream class.
	 * @param array $contextOptions Optional. An array with stream context options.
	 */
	public function __construct( $contextOptions = null ) 
	{
		if ( isset($contextOptions) ) 
		{
			$this->options = $contextOptions;
		}
	}

	/**
	 * This method posts the request to the given url.
	 * @param string $url The url to post to.
	 * @param string $request The request to post.
	 * @return string The server response
	 * @throws Ripcord_TransportException (ripcord::cannotAccessURL) when the given URL cannot be accessed for any reason.
	 */
	public function post( $url, $request ) 
	{
		$options = array_merge( 
			$this->options, 
			array( 
				'http' => array(
					'method' => "POST",
					'header' => "Content-Type: text/xml",
					'content' => $request
				) 
			) 
		);
		$context = stream_context_create( $options );
		$result  = @file_get_contents( $url, false, $context );
		$this->responseHeaders = $http_response_header;
		if ( !$result )
		{
			throw new Ripcord_TransportException( 'Could not access ' . $url, 
				ripcord::cannotAccessURL );
		}
		return $result;
	}
}

/**
 * This class implements the Ripcord_Transport interface using CURL.
 * @package Ripcord
 */
class Ripcord_Transport_CURL implements Ripcord_Transport 
{
	/**
	 * A list of CURL options.
	 */
	private $options = array();
	
	/**
	 * A flag that indicates whether or not we can safely pass the previous exception to a new exception.
	 */
	private $skipPreviousException = false;
	
	/**
	 * Contains the headers sent by the server.
	 */
	public $responseHeaders = null;

	/**
	 * This is the constructor for the Ripcord_Transport_CURL class.
	 * @param array $curlOptions A list of CURL options.
	 */
	public function __construct( $curlOptions = null ) 
	{
		if ( isset($curlOptions) )
		{
			$this->options = $curlOptions;
		}
		$version = explode('.', phpversion() );
		if ( ( (0 + $version[0]) == 5) && ( 0 + $version[1]) < 3 ) { // previousException supported in php >= 5.3
			$this->_skipPreviousException = true;
		}			
	}

	/**
	 * This method posts the request to the given url
	 * @param string $url The url to post to.
	 * @param string $request The request to post.
	 * @throws Ripcord_TransportException (ripcord::cannotAccessURL) when the given URL cannot be accessed for any reason.
	 * @return string The server response
	 */
	public function post( $url, $request) 
	{
		$curl = curl_init();
		$options = (array) $this->options + array(
			CURLOPT_RETURNTRANSFER => 1,
			CURLOPT_URL            => $url,
			CURLOPT_POST           => true,
			CURLOPT_POSTFIELDS     => $request,
			CURLOPT_HEADER         => true
		);
		curl_setopt_array( $curl, $options );
		$contents = curl_exec( $curl );
		$headerSize = curl_getinfo( $curl, CURLINFO_HEADER_SIZE );
		$this->responseHeaders = substr( $contents, 0, $headerSize );
		$contents = substr( $contents, $headerSize );

		if ( curl_errno( $curl ) ) 
		{
			$errorNumber = curl_errno( $curl );
			$errorMessage = curl_error( $curl );
			curl_close( $curl );
			$version = explode('.', phpversion() );
			if (!$this->_skipPreviousException) { // previousException supported in php >= 5.3
				$exception = new Ripcord_TransportException( 'Could not access ' . $url
					, ripcord::cannotAccessURL
					, new Exception( $errorMessage, $errorNumber ) 
				);
			} else {
				$exception = new Ripcord_TransportException( 'Could not access ' . $url 
					. ' ( original CURL error: ' . $errorMessage . ' ) ',
					ripcord::cannotAccessURL
				);
			}
			throw $exception;
		}
		curl_close($curl);
		return $contents;
	}
}

?>