msenkpiel
11/10/2011 - 12:57 PM

advanced json-rpc server (php)

advanced json-rpc server (php)


the dispatcher
===============
<?php

class Dispatcher
{

    /*

- advanced jsonrpc server (php): https://gist.github.com/gists/1354794
- a simple jsonrpc server (php): https://gist.github.com/1344759
- rpc client (js): https://gist.github.com/gists/1354794
- rpc client (as3): https://gist.github.com/1371010


      EXAMPLE
      =======

        var rpc = {
            "method":"MyService.foobar",
            "params":["foo","bar","baz"]
        }

    */



    const ERROR_DISPATCHER_FAILED = "RPC DISPATCHER FAILED";


    // ++++++++++++++++++++ config +++++++++++++++++++++++++++++

    /**
     * register your service enpoints here:
     *
     * e.g. User.Events -> App_Rpc_Service_User_Events
     *
     * means
     *      rpc.method: User.Events.foo()
     *      php: App_Rpc_Service_User_Events::foo()
     * @var array
     */
    protected $_services = array(
        array(
            "endpoint" => "User.Events",
            "class" => "App_Rpc_Service_User_Events",
            /*
            "methods" => array(
                "allow" => array(
                    "*"
                ),
                "deny" => array(
                    "*myPrivateMethod"
                ),
            ),
            */
        ),
        array(
            "endpoint" => "Events.Artists",
            "class" => "App_Rpc_Service_Events_Artists",
            /*
            "methods" => array(
                "allow" => array(
                    "*"
                ),
                "deny" => array(
                    "*myPrivateMethod"
                ),
            ),
            */
        ),

    );

    /**
     * @var array
     */
    protected $_responseHeaders = array(
                "Content-Type: application/json; charset=utf-8",
    );


    // ++++++++++++++++++ run ++++++++++++++++++++++++++++++++


    /**
     * @throws Exception
     * @return void
     */
    public function run()
    {
        $this->_init();
        $this->_run();
    }



    /**
     * @param  $className
     * @return void
     */
    public function phpAutoLoader($className)
    {

        $classPath = dirname(__FILE__);

        $filename = str_replace(
                        "_",
                        "/",
                        $className
                    ).".php";

        $location = $classPath."/".$filename;

        require_once($location);

        if(!class_exists($className)) {
            throw new Exception("classloader failed");
        }

    }

    /**
     * for php<5.3
     * @throws ErrorException
     * @param  $errno
     * @param  $errstr
     * @param  $errfile
     * @param  $errline
     * @return void
     */
    public function phpErrorHandler($errno, $errstr, $errfile, $errline)
    {
        // convert php notice to exception
        throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
    }


     /**
      * @return Hardcoded shutdown handler to detect critical PHP errors.
      */
    public function phpShutdownHandler()
    {

        $error = error_get_last();

        if ($error === null) {
             // no error, we have a "normal" shut down (script is finished).
            return;
        }


        $responseData = array(
            "error" => array(
                "class" => str_replace("_", ".", get_class($this)),
                "message" => "rpc shutdown error"
            ),
            "result" => null,
        );
        echo json_encode($responseData);

    }



    // ++++++++++++++++++++++++++++++++++++++++++++++++++++++

     /**
     * @throws ErrorException
     * @return void
     */
    protected function _init()
    {
        /*
            RECOMMENDED SETUP
            =================
        */
        ini_set("display_errors", true);
        error_reporting(E_ALL|E_STRICT & ~E_NOTICE);

        // turn on error exceptions
        // php 5.3+
        /*
        set_error_handler(function($errno, $errstr, $errfile, $errline){
            throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
        });
        */
        // php < 5.3
        set_error_handler(array($this, "phpErrorHandler"));

        
        // try catch fatal errors
         register_shutdown_function(array($this, 'phpShutdownHandler'));


        spl_autoload_register(array($this, 'phpAutoLoader'));
    }

    /**
      * @param ReflectionClass $reflectionClass
      * @param ReflectionMethod $reflectionMethod
      * @param  object $serviceInstance
      * @param array $params
      * @return void
      */
     protected function _onRpcBeforeInvoke(
         ReflectionClass $reflectionClass,
         ReflectionMethod $reflectionMethod,
         $serviceInstance,
         array $params
     )
     {

         // your hooks here
     }


     /**
      * @param Exception $exception
      * @return void
      */
     protected function _onRpcError(Exception $exception)
     {

          // your hooks here

         // you may want to log sth?
         // you may want to sanitize error data before deliver to client?

         $error = array(
             "class" => str_replace("_", ".", get_class($exception)),
             "message" => $exception->getMessage(),
         );

         $responseData = array(
             "result" => null,
             "error" => $error,
         );

         $this->_sendResponse($responseData);
     }

     /**
      * @param  mixed $result
      * @return void
      */
     protected function _onRpcResult($result)
     {

          // your hooks here
         
         $responseData = array(
             "result" => $result,
             "error" => null
         );

         $this->_sendResponse($responseData);
     }


     /**
      * @param Exception $e
      * @return
      */
     protected function _onDispatcherError(Exception $e)
     {
         // YOU HAVE FUCKING FATAL ERROR IN YOUR HANDLERS!
         // FIX IT MONKEY!

         $responseData = array(
             "error" => array(
                 "class" => str_replace("_", ".", get_class($e)),
                 "message" => self::ERROR_DISPATCHER_FAILED,
             ),
             "result" => null,
         );
         echo json_encode($responseData);
         return;
     }





    // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


    protected function _run()
    {
        $serviceResult = null;
        $serviceError = null;

        try {

            $requestText = "" . $this->_fetchRequestText();
            $rpc = json_decode($requestText, true);

            if (!is_array($rpc)) {
                throw new Exception("Invalid rpc request");
            }
            $rpcMethod = null;
            if (array_key_exists("method", $rpc)) {
                $rpcMethod = $rpc["method"];
            }
            $rpcParams = $rpc["params"];
            if (array_key_exists("params", $rpc)) {
                $rpcParams = $rpc["params"];
                if ($rpcParams === null) {
                    $rpcParams = array();
                }
            }

            if (!is_string($rpcMethod)) {
                throw new Exception("Invalid rpc method");
            }
            if (!is_array($rpcParams)) {
                throw new Exception("Invalid rpc params");
            }


            $rpcMethodParts = (array)explode(".", $rpcMethod);
            $rpcMethodName = "".array_pop($rpcMethodParts);
            $rpcMethodClass = implode(".", (array)$rpcMethodParts);

            // locate class by convention?
            // $rpcMethodClass = str_replace(".", "_", "".$rpcMethodClass);

            $rpcMethodName = "".strtolower(
                trim("".$rpcMethodName)
            );
            $rpcMethodClass = "".strtolower(
                trim("".$rpcMethodClass)
            );


            $serviceClassName = null;
            $serviceClassInfo = null;
            $serviceMethodName = $rpcMethodName;

            $servicesAvailable = (array)$this->_services;
            foreach($servicesAvailable as $serviceInfo) {

                $serviceEndpoint = "".strtolower(
                    trim("".$serviceInfo["endpoint"])
                );

                if(strlen($serviceEndpoint)<1) {
                    throw new Exception("Invalid rpc server config");
                }

                if($serviceEndpoint === $rpcMethodClass) {
                    $serviceClassName = "".strtolower(
                        trim("".$serviceInfo["class"])
                    );
                    if(strlen($serviceClassName)<1) {
                        throw new Exception("Invalid rpc server config");
                    }

                    $serviceClassInfo = $serviceInfo;
                    break;
                }
            }

            try {
                if(!class_exists($serviceClassName)) {
                    throw new Exception("Invalid rpc service class");
                }
                $reflectionClass = new ReflectionClass($serviceClassName);

            } catch (Exception $e) {
                throw new Exception("Invalid rpc service class");
            }



            if (!$reflectionClass->hasMethod($serviceMethodName)) {
                throw new Exception("rpc method does not exist!");
            }
            $reflectionMethod = $reflectionClass->getMethod(
                $serviceMethodName
            );
            $this->_validateReflectionMethod($reflectionMethod);

            $this->_validateServiceMethodName(
                (array)$serviceClassInfo, $reflectionMethod
            );


            $serviceResult = $this->_invokeService(
                $reflectionClass,
                $reflectionMethod,
                $serviceClassName,
                (array)$rpcParams
            );

        } catch (Exception $e) {
            $serviceError = $e;
        }

        try {

            if ($serviceError instanceof Exception) {
                $this->_onRpcError($serviceError);
            } else {
                $this->_onRpcResult($serviceResult);
            }
        } catch (Exception $e) {

            // YOU HAVE FUCKING FATAL ERROR IN YOUR HANDLERS! FIX IT MONKEY!
            $this->_onDispatcherError($e);
            return;
        }

        return;
    }






    /**
     * @param ReflectionClass $reflectionClass
     * @param ReflectionMethod $reflectionMethod
     * @param  $serviceClassName
     * @param array $params
     * @return mixed
     */
    protected function _invokeService(
        ReflectionClass $reflectionClass,
        ReflectionMethod $reflectionMethod,
        $serviceClassName,
        array $params
    )
    {
        $service = new $serviceClassName();

        $this->_onRpcBeforeInvoke(
            $reflectionClass,
            $reflectionMethod,
            $service,
            (array)$params
        );

        $serviceResult = $reflectionMethod->invokeArgs(
            $service, (array)$params
        );
        return $serviceResult;
    }


    /**
     * @return string
     */
    protected function _fetchRequestText()
    {
        $requestText = file_get_contents('php://input');
        return $requestText;
    }


    /**
     * @param array $responseData
     * @return void
     */
    protected function _sendResponse($responseData)
    {

        if(!is_array($responseData)) {
            $responseData = array(
                "result" => null,
                "error" => array(
                    "class" => str_replace("_", ".", get_class($this)),
                    "message" => "invalid rpc response data"
                ),
            );
        }


        // json encode response
        $responseText = null;
        try {
            $responseText = json_encode($responseData);
        } catch(Exception $e) {
            //NOP
        }
        if(!is_string($responseText)) {
            $responseData = array(
                "result" => "null",
                "error" => array(
                    "class" => str_replace("_", ".", get_class($this)),
                    "message" => "rpc encode response failed",
                ),
            );
            $responseText = json_encode($responseData);
        }

        // send response headers
        $this->_sendResponseHeaders();

        // send response text
        echo "".$responseText;

    }


    /**
     * @return void
     */
    protected function _sendResponseHeaders()
    {
        // send response headers
        $headers = $this->_responseHeaders;
        if(!is_array($headers)) {
            $headers = array();
        }
        foreach($headers as $header) {
            if (is_string($header)) {
                try {
                    header($header);
                } catch(Exception $e) {
                    //NOP
                    // ignore warnings "headers already sent"
                }
            }
        }
    }

       /**
     * @throws Exception
     * @param ReflectionMethod $reflectionMethod
     * @return void
     */
    protected function _validateReflectionMethod(
                ReflectionMethod $reflectionMethod
            )
    {
        $reflectionMethodName = $reflectionMethod->getName();
        if ($reflectionMethodName[0] === "_") {
            throw new Exception("rpc method is not invokable!");
        }
        if (!$reflectionMethod->isPublic()) {
            throw new Exception("rpc method is not invokable!");
        }
        if ($reflectionMethod->isStatic()) {
            throw new Exception("rpc method is not invokable!");
        }
        if ($reflectionMethod->isAbstract()) {
            throw new Exception("rpc method is not invokable!");
        }
        if ($reflectionMethod->isInternal()) {
            throw new Exception("rpc method is not invokable!");
        }

        if(method_exists($reflectionMethod, "isConstructor")) {
            if ($reflectionMethod->isConstructor()) {
                throw new Exception("rpc method is not invokable!");
            }
        }
        if(method_exists($reflectionMethod, "isDestructor")) {
            if ($reflectionMethod->isDestructor()) {
                throw new Exception("rpc method is not invokable!");
            }
        }
        
        if(method_exists($reflectionMethod, "isClosure")) {
            if ($reflectionMethod->isClosure()) {
                throw new Exception("rpc method is not invokable!");
            }
        }
        if(method_exists($reflectionMethod, "isDeprecated")) {
            if ($reflectionMethod->isDeprecated()) {
                throw new Exception("rpc method is not invokable!");
            }
        }
    }


    /**
     * @throws Exception
     * @param array $serviceInfo
     * @param ReflectionMethod $reflectionMethod
     * @return null
     */
    protected function _validateServiceMethodName(
                array $serviceInfo, ReflectionMethod $reflectionMethod
            ) {

        $result = null;

        $methodName = $reflectionMethod->getName();

        if(!array_key_exists("methods", $serviceInfo)) {
            return $result;
        }

        $methodsWhiteListed = null;
        $methodsBlackListed = null;
        if(array_key_exists("allow", $serviceInfo["methods"])) {
            $methodsWhiteListed = $serviceInfo["methods"]["allow"];
        }
        if(array_key_exists("deny", $serviceInfo["methods"])) {
            $methodsBlackListed = $serviceInfo["methods"]["deny"];
        }


        if($methodsWhiteListed===null) {
            $methodsWhiteListed = array("*");
        }
        if($methodsBlackListed===null) {
            $methodsBlackListed = array();
        }
        if(!is_array($methodsWhiteListed)) {
            throw new Exception("Invalid rpc serviceConfig.methods.allow");
        }
        if(!is_array($methodsBlackListed)) {
            throw new Exception("Invalid rpc serviceConfig.methods.deny");
        }


        $isWhiteListed = false;
        foreach($methodsWhiteListed as $pattern) {
            $isMatched = fnmatch($pattern, $methodName, FNM_CASEFOLD);
            if($isMatched) {
                $isWhiteListed = true;
                break;
            }
        }
        $isBlackListed = false;
        foreach($methodsBlackListed as $pattern) {
            $isMatched = fnmatch($pattern, $methodName, FNM_CASEFOLD);
            if($isMatched) {
                $isBlackListed = true;
                break;
            }
        }


        $isAllowed = (
                ($isWhiteListed)
                && (!$isBlackListed)
        );

        if(!$isAllowed) {
            throw new Exception("rpc method not allowed");
        }

        return $result;
    }


}
JS Client using jquery ajax and json2.js
========================================

App.invokeRpc = function(method, params, callback)
{
var rpc = {
method:method,
params:params
};

$.ajax({
    url:'rpc.php',
    processData:false,
    data:JSON.stringify(rpc),
    type:'POST',
    success:function(response)
    {
        var resp = response;

        try
        {
            if(typeof(response) == 'string')
            {
                resp = JSON.parse(response);
            }


            if(typeof(resp) != 'object')
            {
                throw new Error("Invalid rpc response");
            }
        }
        catch(e)
        {
            if(typeof(callback) == 'function')
            {
                callback({
                    result:null,
                    error:{
                        message:'Invalid rpc response!'
                    }
                });
            }

            return;
        }

        if(typeof(callback) == 'function')
        {
            callback(resp);
        }
    },
    error:function()
    {
        callback({
            result:null,
            error:{
                message:'XHR ERROR'
            }
        });
    }
});
};