2021-05-21 08:49:41 +02:00

457 lines
13 KiB
PHP

<?php
/**
* Copyright (C) 2015 Datto, Inc.
*
* This file is part of PHP JSON-RPC HTTP.
*
* PHP JSON-RPC HTTP is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License, version 3,
* as published by the Free Software Foundation.
*
* PHP JSON-RPC HTTP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with PHP JSON-RPC HTTP. If not, see <http://www.gnu.org/licenses/>.
*
* @author Spencer Mortensen <smortensen@datto.com>
* @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);
}
}