runcmf/runtracy

View on GitHub
src/RunTracy/Helpers/Console/BaseJsonRpcServer.php

Summary

Maintainability
F
3 days
Test Coverage
<?php

namespace RunTracy\Helpers\Console;

/**
 * JSON RPC Server for Eaze
 *
 * Reads $_GET['rawRequest'] or php://input for Request Data
 * @link       http://www.jsonrpc.org/specification
 * @link       http://dojotoolkit.org/reference-guide/1.8/dojox/rpc/smd.html
 * @package    Eaze
 * @subpackage Model
 * @author     Sergeyfast
 */
class BaseJsonRpcServer
{
    const PARSEERROR = -32700;
    const INVALIDREQUEST = -32600;
    const METHODNOTFOUND = -32601;
    const INVALIDPARAMS = -32602;
    const INTERNALERROR = -32603;

    /**
     * Exposed Instances
     * @var object[]    namespace => method
     */
    protected $instances = [];

    /**
     * Decoded Json Request
     * @var object|array
     */
    protected $request;

    /**
     * Array of Received Calls
     * @var array
     */
    protected $calls = [];

    /**
     * Array of Responses for Calls
     * @var array
     */
    protected $response = [];

    /**
     * Has Calls Flag (not notifications)
     * @var bool
     */
    protected $hasCalls = false;

    /**
     * Is Batch Call in using
     * @var bool
     */
    private $isBatchCall = false;

    /**
     * Hidden Methods
     * @var array
     */
    protected $hiddenMethods = [
        'execute', '__construct', 'registerinstance'
    ];

    /**
     * Content Type
     * @var string
     */
    public $ContentType = 'application/json';

    /**
     * Allow Cross-Domain Requests
     * @var bool
     */
    public $IsXDR = true;

    /**
     * Max Batch Calls
     * @var int
     */
    public $MaxBatchCalls = 10;

    /**
     * Error Messages
     * @var array
     */
    protected $errorMessages = [
        self::PARSEERROR => 'Parse error',
        self::INVALIDREQUEST => 'Invalid Request',
        self::METHODNOTFOUND => 'Method not found',
        self::INVALIDPARAMS => 'Invalid params',
        self::INTERNALERROR => 'Internal error',
    ];


    /**
     * Cached Reflection Methods
     * @var \ReflectionMethod[]
     */
    private $reflectionMethods = [];


    /**
     * Validate Request
     * @return int error
     */
    private function getRequest()
    {
        $error = null;

        do {
            if (array_key_exists('REQUEST_METHOD', $_SERVER) && $_SERVER['REQUEST_METHOD'] != 'POST') {
                $error = self::INVALIDREQUEST;
                break;
            };

            $request = !empty($_GET['rawRequest']) ? $_GET['rawRequest'] : file_get_contents('php://input');
            $this->request = json_decode($request, false);
            if ($this->request === null) {
                $error = self::PARSEERROR;
                break;
            }

            if ($this->request === []) {
                $error = self::INVALIDREQUEST;
                break;
            }

            // check for batch call
            if (is_array($this->request)) {
                if (count($this->request) > $this->MaxBatchCalls) {
                    $error = self::INVALIDREQUEST;
                    break;
                }

                $this->calls = $this->request;
                $this->isBatchCall = true;
            } else {
                $this->calls[] = $this->request;
            }
        } while (false);

        return $error;
    }


    /**
     * Get Error Response
     * @param int $code
     * @param mixed $id
     * @param null $data
     * @return array
     */
    private function getError($code, $id = null, $data = null)
    {
        return [
            'jsonrpc' => '2.0',
            'id' => $id,
            'error' => [
                'code' => $code,
                'message' => isset($this->errorMessages[$code]) ?
                    $this->errorMessages[$code] : $this->errorMessages[self::INTERNALERROR],
                'data' => $data,
            ],
        ];
    }


    /**
     * Check for jsonrpc version and correct method
     * @param \stdClass $call
     * @return array|null
     */
    private function validateCall(\stdClass $call)
    {
        $result = null;
        $error = null;
        $data = null;
        $id = is_object($call) && property_exists($call, 'id') ? $call->id : null;
        do {
            if (!is_object($call)) {
                $error = self::INVALIDREQUEST;
                break;
            }

            // hack for inputEx smd tester
            if (property_exists($call, 'version')) {
                if ($call->version == 'json-rpc-2.0') {
                    $call->jsonrpc = '2.0';
                }
            }

            if (!property_exists($call, 'jsonrpc') || $call->jsonrpc != '2.0') {
                $error = self::INVALIDREQUEST;
                break;
            }

            $fullMethod = property_exists($call, 'method') ? $call->method : '';
            $methodInfo = explode('.', $fullMethod, 2);
            $namespace = array_key_exists(1, $methodInfo) ? $methodInfo[0] : '';
            $method = $namespace ? $methodInfo[1] : $fullMethod;
            if (!$method || !array_key_exists($namespace, $this->instances) ||
                !method_exists($this->instances[$namespace], $method) ||
                in_array(strtolower($method), $this->hiddenMethods)) {
                $error = self::METHODNOTFOUND;
                break;
            }

            if (!array_key_exists($fullMethod, $this->reflectionMethods)) {
                $this->reflectionMethods[$fullMethod] = new \ReflectionMethod($this->instances[$namespace], $method);
            }

            /** @var $params array */
            $params = property_exists($call, 'params') ? $call->params : null;
            $paramsType = gettype($params);
            if ($params !== null && $paramsType != 'array' && $paramsType != 'object') {
                $error = self::INVALIDPARAMS;
                $data = 'Cast of params error';
                break;
            }

            // check parameters
            switch ($paramsType) {
                case 'array':
                    $totalRequired = 0;
                    // doesn't hold required, null, required sequence of params
                    foreach ($this->reflectionMethods[$fullMethod]->getParameters() as $param) {
                        if (!$param->isDefaultValueAvailable()) {
                            $totalRequired++;
                        }
                    }

                    if (count($params) < $totalRequired) {
                        $error = self::INVALIDPARAMS;
                        $data = sprintf(
                            'Check numbers of required params (got %d, expected %d)',
                            count($params),
                            $totalRequired
                        );
                    }
                    break;
                case 'object':
                    foreach ($this->reflectionMethods[$fullMethod]->getParameters() as $param) {
                        if (!$param->isDefaultValueAvailable() && !array_key_exists($param->getName(), $params)) {
                            $error = self::INVALIDPARAMS;
                            $data = $param->getName() . ' not found';

                            break 3;
                        }
                    }
                    break;
                case 'NULL':
                    if ($this->reflectionMethods[$fullMethod]->getNumberOfRequiredParameters() > 0) {
                        $error = self::INVALIDPARAMS;
                        $data = 'Empty required params';
                        break 2;
                    }
                    break;
            }
        } while (false);

        if ($error) {
            $result = [$error, $id, $data];
        }

        return $result;
    }


    /**
     * Process Call
     * @param \stdClass $call
     * @return array|null
     */
    private function processCall(\stdClass $call)
    {
        $id = property_exists($call, 'id') ? $call->id : null;
        $params = property_exists($call, 'params') ? $call->params : [];
        $result = null;
        $namespace = substr($call->method, 0, strpos($call->method, '.'));

        try {
            // set named parameters
            if (is_object($params)) {
                $newParams = [];
                foreach ($this->reflectionMethods[$call->method]->getParameters() as $param) {
                    $paramName = $param->getName();
                    $defaultValue = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
                    $newParams[] = property_exists($params, $paramName) ? $params->$paramName : $defaultValue;
                }

                $params = $newParams;
            }

            // invoke
            $result = $this->reflectionMethods[$call->method]->invokeArgs($this->instances[$namespace], $params);
        } catch (\Exception $e) {
            return $this->getError($e->getCode(), $id, $e->getMessage());
        }

        if (!$id && $id !== 0) {
            return null;
        }

        return [
            'jsonrpc' => '2.0',
            'result' => $result,
            'id' => $id,
        ];
    }


    /**
     * Create new Instance
     * @param object $instance
     */
    public function __construct($instance = null)
    {
        if (get_parent_class($this)) {
            $this->registerInstance($this, '');
        } elseif ($instance) {
            $this->registerInstance($instance, '');
        }
    }


    /**
     * Register Instance
     * @param object $instance
     * @param string $namespace default is empty string
     * @return $this
     */
    public function registerInstance($instance, $namespace = '')
    {
        $this->instances[$namespace] = $instance;
        if (is_object($this->instances[$namespace])) {
            $this->instances[$namespace]->errorMessages = $this->errorMessages;
        }

        return $this;
    }

    /**
     * Get Instances
     * @return \object[]
     */
    public function getInstances()
    {
        return $this->instances;
    }

    /**
     * Handle Requests
     */
    public function execute()
    {
        $ret = [];
        do {
            // check for SMD Discovery request
            if (array_key_exists('smd', $_GET)) {
                $this->response[] = $this->getServiceMap();
                $this->hasCalls = true;
                break;
            }

            $error = $this->getRequest();
            if ($error) {
                $this->response[] = $this->getError($error);
                $this->hasCalls = true;
                break;
            }

            foreach ($this->calls as $call) {
                $error = $this->validateCall($call);
                if ($error) {
                    $this->response[] = $this->getError($error[0], $error[1], $error[2]);
                    $this->hasCalls = true;
                } else {
                    $result = $this->processCall($call);
                    if ($result) {
                        $this->response[] = $result;
                        $this->hasCalls = true;
                    }
                }
            }
        } while (false);

        // flush response
        if ($this->hasCalls) {
            if (!$this->isBatchCall) {
                $this->response = reset($this->response);
            }

            if (!headers_sent()) {
                // Allow Cross Domain Requests
                if ($this->IsXDR) {
                    header('Access-Control-Allow-Origin: *');
                    header('Access-Control-Allow-Headers: x-requested-with, content-type');
                }
            }

            $ret = $this->response;
            $this->resetVars();
        }
        return $ret;
    }


    /**
     * Get Doc Comment
     * @param $comment
     * @return string|null
     */
    private function getDocDescription($comment)
    {
        $result = null;
        if (preg_match('/\*\s+([^@]*)\s+/s', $comment, $matches)) {
            $result = str_replace('*', "\n", trim(trim($matches[1], '*')));
        }

        return $result;
    }


    /**
     * Get Service Map
     * Maybe not so good realization of auto-discover via doc blocks
     * @return array
     */
    private function getServiceMap()
    {
        $result = [
            'transport' => 'POST',
            'envelope' => 'JSON-RPC-2.0',
            'SMDVersion' => '2.0',
            'contentType' => 'application/json',
            'target' => !empty($_SERVER['REQUEST_URI']) ?
                substr($_SERVER['REQUEST_URI'], 0, strpos($_SERVER['REQUEST_URI'], '?')) : '',
            'services' => [],
            'description' => '',
        ];

        foreach ($this->instances as $namespace => $instance) {
            $rc = new \ReflectionClass($instance);

            // Get Class Description
            if ($rcDocComment = $this->getDocDescription($rc->getDocComment())) {
                $result['description'] .= $rcDocComment . PHP_EOL;
            }

            foreach ($rc->getMethods() as $method) {
                /** @var \ReflectionMethod $method */
                if (!$method->isPublic() || in_array(strtolower($method->getName()), $this->hiddenMethods)) {
                    continue;
                }

                $methodName = ($namespace ? $namespace . '.' : '') . $method->getName();
                $docComment = $method->getDocComment();

                $result['services'][$methodName] = ['parameters' => []];

                // set description
                if ($rmDocComment = $this->getDocDescription($docComment)) {
                    $result['services'][$methodName]['description'] = $rmDocComment;
                }

                // @param\s+([^\s]*)\s+([^\s]*)\s*([^\s\*]*)
                $parsedParams = [];
                if (preg_match_all('/@param\s+([^\s]*)\s+([^\s]*)\s*([^\n\*]*)/', $docComment, $matches)) {
                    foreach ($matches[2] as $number => $name) {
                        $type = $matches[1][$number];
                        $desc = $matches[3][$number];
                        $name = trim($name, '$');

                        $param = ['type' => $type, 'description' => $desc];
                        $parsedParams[$name] = array_filter($param);
                    }
                };

                // process params
                foreach ($method->getParameters() as $parameter) {
                    $name = $parameter->getName();
                    $param = ['name' => $name, 'optional' => $parameter->isDefaultValueAvailable()];
                    if (array_key_exists($name, $parsedParams)) {
                        $param += $parsedParams[$name];
                    }

                    if ($param['optional']) {
                        $param['default'] = $parameter->getDefaultValue();
                    }

                    $result['services'][$methodName]['parameters'][] = $param;
                }

                // set return type
                if (preg_match('/@return\s+([^\s]+)\s*([^\n\*]+)/', $docComment, $matches)) {
                    $returns = ['type' => $matches[1], 'description' => trim($matches[2])];
                    $result['services'][$methodName]['returns'] = array_filter($returns);
                }
            }
        }

        return $result;
    }


    /**
     * Reset Local Class Vars after Execute
     */
    private function resetVars()
    {
        $this->response = $this->calls = [];
        $this->hasCalls = $this->isBatchCall = false;
    }
}