. * * @author Spencer Mortensen * @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0 * @copyright 2015 Datto, Inc. */ namespace Datto\JsonRpc\Http; use Datto\JsonRpc\Client as JsonRpcClient; use Datto\JsonRpc\Http\Exceptions\HttpException; use Datto\JsonRpc\Responses\ResultResponse; use ErrorException; /** * Class Client * @package Datto\JsonRpc\Http */ class Client { /** @var string */ private static $METHOD = 'POST'; /** @var string */ private static $CONTENT_TYPE = 'application/json'; /** @var string */ private static $CONNECTION_TYPE = 'close'; /** @var array */ private $requiredHttpHeaders; /** @var string */ private $uri; /** @var array */ private $headers; /** @var resource */ private $context; /** @var JsonRpcClient */ private $client; /** @var array */ private $responses; /** * Construct a JSON-RPC 2.0 client. This will allow you to send queries * to a remote server. * * @param string $uri * Address of your JSON-RPC 2.0 endpoint. * * Example: * $uri = "https://api.example.com"; * * @param null|array $headers * An associative array of the raw HTTP headers that you'd like to send * with your request. * * Note that the CONTENT_TYPE, CONNECTION_TYPE, and METHOD headers are * automatically applied, since they are required for every request, * so you don't need to add them. * * You might need to add headers if you're using an authenticated API: * * Example 1. Add the header for basic access authentication: * $authentication = base64_encode("{$username}:{$password}"); * $headers = array( * 'Authorization' => "Basic {$authentication}" * ); * * @param null|array $options * An associative array of the PHP stream context options that you'd like to use. * This can be useful if you'd like to customize the details of your connection. * * Example 1. Disable SSL verification (this might be useful for a development * environment without a valid SSL certificate): * * $options = array( * 'ssl' => array( * 'verify_peer' => false, * 'verify_peer_name' => false * ) * ); * * @see http://php.net/manual/en/context.ssl.php SSL options * * Example 2. Add an HTTP timeout limit (so queries won't hang forever): * * $options = array( * 'http' => array( * 'timeout' => 5 * ) * ); * * @see http://php.net/manual/en/context.http.php HTTP options */ public function __construct($uri, array $headers = null, array $options = null) { $this->requiredHttpHeaders = array( 'Accept' => self::$CONTENT_TYPE, 'Content-Type' => self::$CONTENT_TYPE, 'Connection' => self::$CONNECTION_TYPE ); $headers = array_merge(self::validHeaders($headers), $this->requiredHttpHeaders); $options = self::validOptions($options); $context = self::getContext($options); $client = new JsonRpcClient(); $this->uri = $uri; $this->headers = $headers; $this->context = $context; $this->client = $client; $this->responses = []; } /** * Queue up a message! You can queue up any number of messages here. * Then, when you're ready, call the "send" method to send all of the messages * in a single HTTP(S) request. * * Other APIs involve a long series of back-to-back roundtrips, but JSON-RPC * allows you to get everything done in just one trip! This is wonderful for * your latency. * * @param string $method * The method that you're calling on the remote server * * @param array $arguments * The arguments that you're supplying to the method * * @param mixed $response * After you've called this "query" method to queue up your message, call the * "send" method to send your message. You'll see a value appear here, * in this "$response" object (through the magic of pass by reference). * * More than likely, you'll receive the raw value that you were expecting * from the server. But, if the server was unable to successfully process * your request, you could wind up with an "ErrorResponse" object instead: * @see https://github.com/datto/php-json-rpc/tree/master/src/Responses/ErrorResponse ErrorResponse * * But be sure to check the type of the "$response" before you use it! * * @return self * Returns the object handle (so you can chain method calls if you're into * that kinky stuff) */ public function query($method, $arguments, &$response) { $id = count($this->responses); $this->responses[$id] = &$response; $this->client->query($id, $method, $arguments); return $this; } /** * Queue up a message! You can queue up any number of messages here. * Then, when you're ready, call the "send" method to send all of the messages * in a single HTTP(S) request. * * Other APIs involve a long series of back-to-back roundtrips, but JSON-RPC * allows you to get everything done in just one trip! This is wonderful for * your latency. * * This "notify" method sends a message to the server that does NOT need any * response. When you don't need a response, this is more efficient than * using the "query" method, since the server doesn't have to generate * a response, and you don't have to wait for one. * * @param string $method * The method that you're calling on the remote server * * @param array $arguments * The arguments that you're supplying to the method * * @return self * Returns the object handle (so you can chain method calls if you're into * that kinky stuff) */ public function notify($method, array $arguments) { $this->client->notify($method, $arguments); return $this; } /** * This method sends your requests over HTTP(S) to the remote server. * * @throws ErrorException * Throws an "ErrorException" if an error occurred before an HTTP response could be received. * * @throws HttpException * Throws an "HttpException" if the server responded with a failure HTTP status code. */ public function send() { set_error_handler([__CLASS__, 'onError']); try { $options = $this->getStreamOptions(); stream_context_set_option($this->context, $options); $message = file_get_contents($this->uri, false, $this->context); $this->throwHttpExceptionOnHttpError($http_response_header); $this->deliverResponses($message); } catch (ErrorException $exception) { $this->throwHttpExceptionOnHttpError($http_response_header); throw $exception; } finally { restore_error_handler(); } } private function throwHttpExceptionOnHttpError($header) { if (!is_array($header) || (count($header) === 0)) { return; } $response = HttpResponse::fromHttpResponseHeader($header); $code = $response->getCode(); if ($code === 200) { return; } throw new HttpException($response); } /** * Cancel any queries or notifications that you had previously queued: * They will not be sent. */ public function reset() { $this->client->reset(); $this->responses = []; } /** * View the HTTP headers that will be sent on each request. * * @return array * An associative array containing the raw HTTP headers that will be sent * with each request. */ public function getHeaders() { return $this->headers; } /** * Set add an additional HTTP header. This additional header will be sent * on each future HTTP request. * * @param string $name * The name of the HTTP header (e.g. "Authorization"). * * @param string $value * The value of this HTTP header (e.g. "Basic dXNlcm5hbWU6cGFzc3dvcmQ="). * * @return boolean * True iff the header has been set successfully (or has had the desired * value all along). Note that the CONTENT_TYPE, CONNECTION_TYPE, and * METHOD headers cannot be changed (because those headers are required). */ public function setHeader($name, $value) { if (!self::isValidHeader($name, $value)) { return false; } if (isset($this->requiredHttpHeaders[$name])) { return $this->requiredHttpHeaders[$name] === $value; } $this->headers[$name] = $value; return true; } /** * Unset an existing HTTP header. This HTTP header will no longer be sent * on future requests. * * @param string $name * The name of the HTTP header (e.g. "Authorization"). * * @return boolean * True iff the header was successfully removed (or was never set in the * first place). Note that the CONTENT_TYPE, CONNECTION_TYPE, and METHOD * headers are required, so those headers cannot be unset. */ public function unsetHeader($name) { if (!self::isValidHeaderName($name)) { return true; } if (isset($this->requiredHttpHeaders[$name])) { return false; } unset($this->headers[$name]); return true; } private static function validHeaders($headers) { if (!self::isValidHeaders($headers)) { $headers = array(); } return $headers; } private static function isValidHeaders($input) { if (!is_array($input)) { return false; } foreach ($input as $name => $value) { if (!self::isValidHeader($name, $value)) { return false; } } return true; } private static function isValidHeader($name, $value) { return self::isValidHeaderName($name) && self::isValidHeaderValue($value); } private static function isValidHeaderName($name) { return is_string($name) && (0 < strlen($name)); } private static function isValidHeaderValue($value) { return is_string($value) && (0 < strlen($value)); } private static function validOptions($options) { if (!is_array($options)) { return array(); } $supportedOptions = array( 'http' => true, 'ssl' => true ); return array_intersect_key($options, $supportedOptions); } private static function getContext($options) { set_error_handler([__CLASS__, 'onError']); try { return stream_context_create($options); } finally { restore_error_handler(); } } private function getStreamOptions() { $content = $this->client->encode(); $headers = $this->headers; $headers['Content-Length'] = strlen($content); $header = $this->getHeaderText($headers); return array( 'http' => array( 'method' => self::$METHOD, 'header' => $header, 'content' => $content ) ); } private function getHeaderText($headers) { $header = ''; foreach ($headers as $name => $value) { $header .= "{$name}: {$value}\r\n"; } return $header; } private function deliverResponses(string $message) { $responses = $this->client->decode($message); foreach ($responses as $response) { $id = $response->getId(); if (!array_key_exists($id, $this->responses)) { $idText = json_encode($id); throw new ErrorException("Received a surprise response from the server with id {$idText}"); } if ($response instanceof ResultResponse) { $value = $response->getValue(); } else { $value = $response; } $this->responses[$id] = $value; unset($this->responses[$id]); } foreach ($this->responses as $id => $response) { $idText = json_encode($id); throw new ErrorException("Expected a response from the server with id {$idText}, but received nothing! Disappointed!"); } } public static function onError($level, $message, $file, $line) { $message = trim($message); $code = null; throw new ErrorException($message, $code, $level, $file, $line); } }