Powered by JSON-RPC 2.0

Overview


It’s hard to imagine a modern software system which doesn’t either consume or provide a means of machine to machine communication. In fact most applications both provide and depend on various web services, APIs etc.

The idea of web services or online APIs isn’t new. There is a dedicated style of the software architecture which focuses on methodologies, communication protocols and structures of the applications which are built around the concept of web services. It is called service-oriented architecture (SOA) and is also applied in the field of software design.

Many different protocols and methodologies have been developed for composing web services and providing descriptions for them. This article is not intended to classify them, but for simplicity we can divide the specifications into two groups: RPC family and REST APIs.

The software developed in our company doesn’t differ from the others in the matter of web service utilization. Our systems are integrated to dozens of APIs and provide web-services for even more other applications. The main protocol used for web services is JSON-RPC 2.0.

The power of JSON-RPC


JSON-RPC is of course not the only RPC protocol, but it is known for its simplicity and brief specification. As it can be implied from its name, the protocol uses JSON as the data serialization format. JSON is widely used and is considered to be more concise compared to other human readable serialization formats like XML. Although XML is a mature technology and supports extension natively (which can be “guessed” from its name), JSON got popularity along with wide adaptation of JavaScript. JSON is also used in the majority of REST API as well.

JSON-RPC powers the language server protocol (LSP). Many programming languages implement it for integration with text editors and IDEs in order to provide better experience during application development.

The few notable features of the protocol are being transport agnostic (can be used with raw TCP/UDP connections and is great with web sockets), support of notifications (client's lack of interest in the corresponding Response object) and batch request processing functionality.

{
  "jsonrpc": "2.0",
  "method": "subtract",
  "params": [
    42,
    23
  ],
  "id": 1
}
Fig. 1. JSON-RPC2.0 request example

The simplicity of the protocol allowed us to implement both client and server libraries ourselves, thus not depending on third-party libraries. Almost all software developed in VXSoft provide JSON-RPC based web-services both for administrative purposes and for integrating with other systems.

import uuid
import requests


requests.post(
    'http://localhost/json-rpc',
    json={
        'jsonrpc': '2.0',
        'method': 'subtract',
        'params': [42, 23],
        'id': str(uuid.uuid4())
    }
)
Fig. 2. Simple JSON-RPC2.0 request using Python's "requests" library

One of the disadvantages of the JSON-RPC 2.0 specification is the lack of rules for describing a service like WSDL for SOAP. Another limitation of the protocol is missing security standards.

To solve the problem with a service description, after some research we stumbled upon OpenRPC specification. It is specifically designed for describing and documenting JSON-RPC 2.0 based web service and is using JSON schema for defining requests and responses. By utilizing the OpenRPC we can generate machine readable structures of our services and then automatically use them in client libraries for composing requests and validating responses. The description of a service documented using OpenRPC specification can be discovered by the /openrpc.json path of the URL.

{
  "openrpc": "1.3.1",
  "info": {
    "title": "My JSONRPC2.0 service",
    "version": "0.0.1",
    "license": {
      "name": ""
    }
  },
  "servers": [
    {
      "url": "/json-rpc"
    }
  ],
  "methods": [
  ]
}
Fig. 3. Basic OpenRPC definition

A service description may include generic information about a web-service including its version and license, the URL path of the service and definition of its methods.

A method definition by an OpenRPC specification includes it's name, summary and the structure of both request and response objects. It may also provide properties for indicating whether a certain method is deprecated or not and how the method is going to receive it's arguments (positional and named arguments). The request and response objects are defined using JSON schema.

{
  "methods": [
    {
      "name": "start_transaction",
      "summary": "Start signing transaction",
      "tags": [
        {
          "name": "t"
        }
      ],
      "params": [
        {
          "name": "title",
          "required": true,
          "schema": {
            "type": "object"
          },
          "deprecated": false
        },
        {
          "name": "callback",
          "required": true,
          "schema": {
            "type": "string"
          },
          "deprecated": false
        },
        {
          "name": "file",
          "required": true,
          "schema": {
            "type": "object"
          },
          "deprecated": false
        },
        {
          "name": "signers",
          "required": true,
          "schema": {
            "type": "array"
          },
          "deprecated": false
        }
      ],
      "result": {},
      "deprecated": false,
      "paramStructure": "by-name"
    }
  ]
}
Fig. 4. A JSON-RPC method definition using OpenRPC specification

Fig.4 presents a description of start_transaction method with four arguments: title, callback, file, signers. It also indicated that the method isn't deprecated ("deprecated": false) and that the arguments must be passed by name ("paramStructure": "by-name"). The result key must contain the structure of the JSON-RPC 2.0 result object.

The definition of all methods provided by a single service is generated from our code automatically. As our code base is in Python we can use the dynamic nature of the language and existing type annotations for composing the OpenRPC document. A (very) simplified and (very) limited implementation of a method definition generation may look like this:

from enum import StrEnum
from typing import Callable


def openrpc_spec(
    functions: list[Callable],
    title: str,
    version: str,
    server: str
) -> dict:
    # mapping between Python's built-in types and JSON types
    typedef: dict = {
        int: "number",
        float: "number",
        str: "string",
        bool: "boolean",
        list: "array",
        tuple: "array",
        dict: "object",
    }

    methods: list = []
    for func in functions:
        summary: str = func.__doc__ or ''
        params: list = []

        for param, typ in func.__annotations__.items():
            # skip the result definition
            if param == 'return':
                continue

            if jtype := typedef.get(typ):
                schema = {"type": jtype}
            elif issubclass(typ, StrEnum):
                schema = {"type": "string", "enum": tuple(typ)}
            else:
                schema = {"type": str(typ)}

            # generate the definition for the method
            params.append({
                "name": param,
                "required": True,
                "schema": schema,
                "deprecated": False,
            })

        methods.append({
            "name": func.__name__,
            "summary": summary and summary.strip(),
            "tags": [{"name": "t"}],
            "params": params,
            "result": '{}',
            "deprecated": False,
            "errors": [],
            "paramStructure": "by-name",  # by-position | either
        })

    return {
        "openrpc": "1.3.1",
        "info": {
            "title": title,
            "version": version,
            "license": {"name": ""},
        },
        "servers": [{"url": server}],
        "methods": methods,
    }


def square(number: int) -> None:
    ...


def append(value: str, collection: list, unique: bool) -> list:
    ...


print(
    openrpc_spec(
        [square, append],
        'My JSON-RPC 2.0 service',
        '0.0.1',
        '/json-rpc'
    )
)
Fig. 5. OpenRPC method definition generation in Python

The code in fig.5 tries to map built-in Python data types to JSON schema defined types and also tries to detect some types like StrEnum. It is simplified for the article and obviously lacks the response object structure generation and nested object definition, but it's a step in the right direction and may work for simple services. We run a slightly modified and complete version of this code for generating openrpc.json files.

Conclusion


Being able to easily provide and consume a web service or an API is an essential part of the modern IT infrastructure. In the world of complex standards and protocols it's always a nice thing to have a simpler approach for solving a problem. For our company that simpler approach for web service development was choosing JSON-RPC 2.0 as our main protocol. Spicing it with OpenRPC specification we are able to provide web services to hundreds of our clients who can easily integrate to the system we are developing.
Of course there isn't a silver bullet for solving all issues or a unified technology for all aspects of life. Thus everyone must find their own niche toolbox. 

No comments:

Powered by Blogger.