Popularizing JSON-RPC (Part 1)

There are well-established standards for transmitting data over a network. Among the main ones: SOAP, gRPC, AMQP, REST, GraphQL.

When creating websites of low, medium and high complexity with data flows to the backend and back in JSON format, the last two are usually used with their variations. Rather, only options, because REST and GraphQL are resource-oriented protocols. It’s like just transferring elementary work with the database to the client. Typically, such requests are no more than a third of the entire backend API.

Trying to make the entire API as RESTful as possible is terribly bloated code and loads the network. Therefore, the remaining two-thirds of the requests are in the form of commands to the backend to do some work that is poorly displayed on CRUD on a certain resource. And there are a lot of options to send such requests. Even too much.

GET, POST, PUT, PATCH, HTTP headers, cookies, body, form data, GET query parameters, json, content-type, HTTP codes… When there are several programmers in a team and everyone has their own view of the world, it’s pretty fast turns into a vinaigrette. Even one full-stack developer often finds himself at a dead end in front of all this mess of parameters, verbs and nouns of the RESTlike API, not understanding how to live with it.

All this led to the creation of a simple and understandable, but from my point of view, greatly underestimated JSON-RPC specification (https://www.jsonrpc.org/specification) that separates the business logic of a client-server request from the network protocol itself (HTTP) with its rich but not always necessary inner world.

In JSON-RPC, all requests are standardized to go through an HTTP POST in the form of a JSON object (in principle, JSON-RPC 2.0 is a transport-independent protocol, but we consider its most common use). Data exchange is strict and clear. The request has method and params. Method plays the role of an endpoint/command, params – parameters. Server responses come in approximately the same form.

RPC stands for Remote procedure call, that is, a command is sent to the backend to execute some code. Team in meaning and purpose can be any. This is the difference between RPC and REST, which is limited to four CRUD actions on some resources.

The JSON in the name means that the exchange of information between the client and the server (microservices) goes through JSON data format.

You can read more about JSON-RPC, for example, here – https://habr.com/en/post/441854/ or in many other places. Nothing complicated. In this article, I would like to focus on its advantages, and not a description.

Also, for those who think about the competent design of the networked API, I recommend Google articles on this topic – https://cloud.google.com/apis/design. The architecture for the API is just as important as for the software system itself. There is even such a direction – API Driven Architecture, although in my experience some kind of alpha version of the application is usually made first, and then its API is refactored and unified.

pros

JSON-RPC separates the business logic from the network protocol. On this, in fact, one could set off fireworks and end. Yes, it facilitates communication between front and back developers, gives a better structure and understanding of data, ease of development, protocol independence, but, from my point of view, the main thing is the above department.

What does this mean for the frontend? I usually make an API access module and work through it:

import api from '@/api';

api.products.list();
api.users.update(userId, {"balance", 100});

Inside list() and update() requests via fetch() or axios() to the desired backend API endpoints. The transition to the JSON-RPC standard is not difficult, it can be carried out gradually and does not bring much profit in coding, except for the absence of the need to think about what to use – POST, PUT or PATCH, and how to pass parameters, how to handle the incoming result, errors, etc.

The backend is a completely different matter. I am a big fan CodeIgniter 4 (hereinafter referred to as CI), I consider it the best PHP framework for small and medium-sized APIs, and I will speak on its example, but Laravel, Sping Boot, Django work approximately on the same principle.

On the back, each request is processed by the controller (which are now in fact already a vestige of the MVC architectural pattern of the content generation times on the server), to which the (conditionally) HttpRequest and HttpMessage are passed. One controller can handle several endpoints, this is written in the framework’s routing. The controllers have access to the details of the transport (HTTP) protocol. Often, controllers contain all business logic, including work with the database and other internal services.

What happens when you decide to change frameworks because you’ve found a better one? You are rewriting controllers. What happens when your framework is drastically updated and you like it, you want to use it, but there are a lot of breaking changes? You are rewriting controllers. And this is most of the code. And it is quite difficult to gradually switch to the new version, only at once. All because your logic built in to the framework.

What about JSON-RPC? There is only one controller there, which processes all requests and redirects them to the necessary modules with its internal routing. Here is all HTTP routing in CI:

$routes->post('rpc', 'JsonRpcController::index');

Here is the internal routing file used by JsonRpcController:

JsonRpcRoutes.php
<?php

namespace App\Controllers;

class JsonRpcRoutes
{

    public static $basePath = "App\src\\";

    public static $routes = [
        "users" => [
            "transactions:list" => "Users\Transactions::list",
            "withdrawal:create" => "Users\Transactions::createWithdrawal",
        ],
        "utils" => [
            "resources:list" => "Utils\Resources::list",
            "resources:update" => "Utils\Resources::update",
            "resources:getByKey" => "Utils\Resources::getByKey",
            "resources:updateByKey" => "Utils\Resources::updateByKey",
            "resourceCache:clear" => "Utils\Resources::clearCache",
        ],
    ];


    public static function route($method) {
        $path = explode('.' , $method);
        $route = self::$routes;
        foreach ($path as $step) {
            $route = $route[$step];            
        }
        return $route;
    }


    
}

Hierarchical model in $routes set for convenience. Function route() takes the request method (method – “flat” version of the route, for example, users.transactions:list) and returns the corresponding class and function on it.

The choice of method name notation is entirely up to the developer, but I like the Google recommendations from the link above.

Utils\Resources and Users\Transactions are just PHP classes responsible for business logic. They receive data in the form of an object, and they give the result in the form of an object. No connection to the HTTP protocol. If you need to change the framework, then you need to rewrite only one file – JsonRpcController.php

Well, he himself:

JsonRpcController.php
<?php

namespace App\Controllers;

use CodeIgniter\Controller;
use stdClass;

class JsonRpcController extends Controller
{
    public function index()
    {
        try {
            $payloadData = $this->request->getJSON();
        } catch (\Throwable $th) {
            return $this->response->setJSON($this->errorResponse(-32700));
        }        
        $response = null;
        
        try {            
            // batch payload
            if (is_array($payloadData)) {
                if (count($payloadData) == 0) {
                    return $this->response->setJSON($this->errorResponse(-32600));
                }
                $response = [];
                foreach ($payloadData as $payload) {
                    $singleResponse = $this->processRequest($payload);
                    if ($singleResponse != null) {
                        $response[] = $singleResponse;
                    }
                }
                if (count($response) > 0) {
                    return $this->response->setJSON($response);
                }
            // single request
            } else if (is_object($payloadData)) {
                $response = $this->processRequest($payloadData);
                return $this->response->setJSON($response);
            } else {
                return $this->response->setJSON($this->errorResponse(-32700));
            }
        } catch (\Throwable $th) {
            return $this->response->setJSON($this->errorResponse(-32603, null, [
                "msg" => $th->getMessage(),
                "trace" => $th->getTrace()
            ]));
        }
    }

    /**
     * Process single JSON-RPC request.
     *
     * @param object    $paylod Request object
     * @return object   Response object
     */
    private function processRequest($payload) {
        if (!is_object($payload)) {
            return $this->errorResponse(-32700);
        }            
        if (!property_exists($payload, "jsonrpc") && !property_exists($payload, "method")) {
            return $this->errorResponse(-32600);
        }      
        $payload->context = new stdClass();
        if ($this->request->currentUser ?? NULL) {
            $payload->context->user = $this->request->currentUser;
        }

        $route = JsonRpcRoutes::route($payload->method);
        if (!$route) {
            return $this->errorResponse(-32601, $payload->id);
        }

        list($className, $methodName) = explode("::", $route);
        $className = JsonRpcRoutes::$basePath . $className;
        $outcome = (new $className())->$methodName($payload);

        if (!property_exists($payload, "id") || !$outcome) {
            return null;
        }
        
        $data = [
            "jsonrpc" => "2.0",
            "id" => $payload->id
        ];
        return array_merge($data, $outcome);
    }

    /**
     * Used for generic failures.
     *
     * @param int       $errorCode   according to JSON-RPC specification
     * @return Object   Response object for this error
     */
    private function errorResponse($errorCode, $id = null, $data = null) {
        $response = [
            "jsonrpc" => "2.0",
            "error" => [
                "code" => $errorCode,
                "message" => ''
            ],
            "id" => $id
        ];
        if ($data) {
            $response["error"]["data"] = $data;
        }
        switch ($errorCode) {
            case '-32600':
                $response["error"]["message"] = "Invalid Request";
                break;            
            case '-32700':
                $response["error"]["message"] = "Parse error";
                break;            
            case '-32601':
                $response["error"]["message"] = "Method not found";
                break;            
            case '-32602':
                $response["error"]["message"] = "Invalid params";
                break;            
            case '-32603':
                $response["error"]["message"] = "Internal error";
                break;            
            default:
                $response["error"]["message"] = "Internal error";
                break;
        }
        return $response;
    }

}

The controller could be three to four times smaller, but I wanted to implement the requirements of the JSON-RPC 2.0 specification to the maximum. In fact, it simply takes a route from JsonRpcRoutes and calls the appropriate method on the required class, passing parameters to it.

Yes, there are other commonly used services in backend frameworks – for example, to simplify access to data in the database (Active records (not ORM) in CodeIgniter 4 – a very effective solution that generates efficient SQL, perhaps the only thing worth using CI for, but not clean rpc.php as an input from a web server), and they will need to be adapted in case of a possible move, but if desired, the business logic module (Utils\Resources and Users\Transactions) can be written in pure PHP/Java/Python, or you can use third-party libraries yourself and be completely independent from the framework. But frameworks really want to bind developers to themselves.

And how much easier it is to test your classes, not controllers, how much easier it is to build an application architecture without the restrictions of the framework, its context, execution flow and magic.

I’m not talking about code reuse: calling another controller’s method from one controller in order to apply its response for issuing to the client is not a trivial task. Because the input and output in the controllers – through the protocol-dependent gateways of the framework. Yes, it is possible to pull all the logic out of the controllers and use them as empty wrappers/proxy – but then what would be their point? Yes, and that’s exactly what JSON-RPC does, including.

The current backend frameworks were created when everything was still generated on the server and the finished html was sent to the client. For all this, server sessions, template engines and other things of server frameworks were needed. For modern SPA-oriented backend APIs, all this is not necessary, it is a heavy load, which often interferes with working with modern technologies like shackles.

Conclusion

JSON-RPC gives developers freedom.

Unfortunately, JSON-RPC still belongs to esoteric knowledge, the coolness of which you understand only after you try it, and only developers who have reached a certain level of fatigue from such a life decide to try. Most continue to make clumsy rest-like bikes every time.

I would even say that JSON-RPC is the only standard that can be used when building communication with the backend in 90% of modern web and mobile applications (if you do not look towards gRPC and exotics). And which is really useful in DX. Everything else is a big compromise.

In the second part, I will focus on several features when working with JSON-RPC, namely: why do we need batch packets, authentication and authorization, how to see semantically clear information about requests in DevTools / Network, and not just /rpcand how to become the master of the backend framework, not its slave.

Similar Posts

Leave a Reply Cancel reply