JSON-RPC over WS

Overview

This tutorial demonstrates how Mongoose Library can be used to implement JSON-RPC functionality over WebSocket:

  • We start a server that hosts a simple web page
  • The user requests this page with a browser, which processes its contents
  • The JavaScript code in the web page starts a WebSocket connection to our server
  • The web page and our backend exchange JSON-RPC frames over that WebSocket connection
  • The web page receives real-time notifications (RPC frames without id)
  • The web page can send RPC requests to call RPC functions on the backend

Full source code for this tutorial is at examples/json-rpc-over-websocket

Why JSON-RPC

JSON-RPC is a remote procedure call protocol encoded in JSON. Here is an example of a request and its response:

{"id": 12, "method": "sum", "params": [2,3]}   // Request
{"id": 12, "result": 5}                        // Response

One might ask, why do we need it? Are RESTful capabilities not enough? RESTful is doing the same thing, request/response, and it can also wrap data into JSON, right? Right. But there are a few differences, some of them can be crucial and hence be a decision factor.

  • Notifications. With RESTful, there is no straight way to send notifications from server to client. With JSON-RPC, any side at any point can send requests. JSON-RPC requests without id do not require responses, thus, they can be treated as notifications.
  • Performance. A single WebSocket connection, once established, can be used to send multiple requests in either direction. Websocket protocol is very lightweight, and uses only a few byte header per message. With RESTful, requests are either pipelined or require multiple connections, causing a bigger overhead and potential connection setup costs.

In this tutorial, we present a simple JavaScript JSON-RPC client and a backend server implementation. The client can send requests to the server and receive asynchronous real-time notifications as well.

JSON-RPC basics

  • JSON-RPC is a standardised protocol, documented at JSON-RPC 2.0. Note that this is a tutorial, a simple example, you can change what is sent at will by just modifying the source code. Mongoose does not claim compliancy to any version of the standard.
  • Requests and responses are simple JSON strings, and often are called "frames"
  • Requests and responses can be exchanged over any media/protocol: MQTT, HTTP, WebSocket, and so on. In this tutorial, we use WebSocket
  • Request frames have a mandatory method attribute, and optional params attribute
  • If request frame has an optional id attribute, then a server must send a response frame:
    {"id": 12, "method": "sum", "params": [2,3]}   // Request frame
    {"id": 12, "result": 5}                        // Response frame
    
  • Frames without id are considered notification frames. Server does not respond on notification frames
    {"method": "hiya", "params": {"data": "hi"}}   // Notification frame
    
  • On error, server replies with an error frame, e.g.:
    {"id":1, "error": {"code": 500, "message": "oops..."}}
    
  • Communication is bidirectional: either side can send requests

Build and test

Follow the Build Tools tutorial to setup your development environment. Then start a command prompt / terminal, clone the Mongoose repository and build the example:

git clone https://github.com/cesanta/mongoose
cd mongoose/examples/json-rpc-over-websocket
make

Start your browser on http://localhost:8000 and you should see a simple web page:

demo web page

Click on the "connect" button. The JavaScript code on the web page starts a JSON-RPC instance, which makes a WebSocket connection to the server.

Wait for a couple of seconds, you'll see a notification from the server:

JSON-RPC notification

Now click on the "Calculate" buttons. Each click will trigger a JSON-RPC request. Notice that for each request, a new id is generated. This allows the client to differentiate responses, because they could come out of order.

WS server

We can also get a list of all the available methods by invoking the rpc.list method:

wscat --connect ws://localhost:8000/websocket
Connected (press CTRL+C to quit)
> {"id":123, "method":"rpc.list"}
< {"id":123,"result":["rpc.list","mul","sum"]}

Client side

First, let's see what's on the client side. The JavaScript code in the browser is simple and straightforward. We get the elements that are present on the page. On a "connect" button click, we start an RPC instance:

The JSON-RPC client code is located in a different file:

The jsonrpc() call accepts a WebSocket URL, and 3 callbacks: when a WebSocket connection opens, when it closes, and when a JSON-RPC notification arrives. It returns an object with two methods, close() and call(). The close() method simply closes the WebSocket connection - we need that to handle when the user clicks on the "disconnect" button. The call() method is triggered when the user presses one of the "Calculate" buttons. Note that call parameters are hardcoded just to make the example simpler:

Server side

The JSON API

The Mongoose library includes a set of functions to make life easier when dealing with JSON messages. We've already introduced mg_mprintf() above, let's focus on some of the JSON parsing functions:

int mg_json_get(const char *buf, int len, const char *path, int *toklen);
bool mg_json_get_num(struct mg_str json, const char *path, double *v);
bool mg_json_get_bool(struct mg_str json, const char *path, bool *v);
char *mg_json_get_str(struct mg_str json, const char *path);

The function mg_json_get() will parse a JSON string in a buffer pointed to by parameter buf, with length len, searching for the element specified by the JSON path path. If successful, it will store the element length at placeholder toklen and return its offset inside the buffer. Otherwise, it will return an error code.

The other three functions make use of an internal Mongoose structure, mg_str. This form to represent a string is used in many places in Mongoose, for example the struct mg_ws_message we received in the listing above as the parameter for the event MG_EV_WS_MSG uses it. We can convert a NULL-terminated C string into a struct mg_str with the function mg_str(), or we can also use mg_str_n() that requires us to specify a length and handles non-NULL-terminated strings.

The function mg_json_get_num() will parse a JSON string in a struct mg_str, searching for a valid numeric value at the JSON path path. If successful, it will store its value at placeholder v and return true; returning false otherwise.

The function mg_json_get_bool() is similar, though it searches for a boolean value.

The function mg_json_get_str() will parse a JSON string in a struct mg_str, searching for a string value at JSON path path. If successful, it will allocate memory, un-escape the string, and return a pointer to it; returning NULL otherwise. The caller must free the buffer later.

For detailed information, see the documentation.

The RPC API

Mongoose library also includes a set of functions to perform RPC oriented tasks:

void mg_rpc_add(struct mg_rpc **head, struct mg_str method_pattern,
                void (*handler)(struct mg_rpc_req *), void *handler_data);
void mg_rpc_del(struct mg_rpc **head, void (*handler)(struct mg_rpc_req *));
void mg_rpc_process(struct mg_rpc_req *);
void mg_rpc_ok(struct mg_rpc_req *, const char *fmt, ...);
void mg_rpc_err(struct mg_rpc_req *, int code, const char *fmt, ...);
void mg_rpc_list(struct mg_rpc_req *r);

The function mg_rpc_add() will add the method method_pattern to the list head of RPC methods, executing handler when invoked.

The function mg_rpc_del() will remove one or all methods from the list head of RPC methods.

The function mg_rpc_process() will process a request req.

The functions mg_rpc_ok() and mg_rpc_err() are helpers to print results.

The function mg_rpc_list() is a method that lists all available methods

For detailed information, see the documentation.

Processing requests

The event handler is quite similar to what we've seen on the WebSocket server tutorial:

If a connection is still HTTP, we upgrade it to WebSocket if the URI is /websocket; otherwise we serve static content from the web_root directory. When a WebSocket connection gets established, we set a mark in c->data in order to let the timer function know who should be notified.

When a WebSocket frame is received from a client, we got ourselves a JSON message to process. We'll put that inside the frame of a request structure and handle it to mg_rpc_process() to invoke the requested method. Note we pass a pointer to mg_pfn_iobuf(), a character printing function, and build a response in an IO buffer (a simple data structure that inserts or deletes chunks of data at arbitrary offsets and grows/shrinks automatically) prior to handle it to the send function.

Finally, we need two functions for our supported methods, sum() and mul(), to add and multiply numbers respectively.

main() and initialization

The server initialization is similar to what we've seen on the WebSocket server tutorial, with the addition of the RPC section.

The RPC processor is initialized by adding the methods, as we've seen.

Here, we also initialized a timer to handle sending notifications, as can be seen on the timers tutorial. This timer will kick in every 5 seconds and send JSON-RPC notifications to all connected clients:

To craft these JSON messages, we take advantage of the function mg_ws_printf(). It is similar to printf(), with a convenient extension to the syntax in which the specifier m is used to print double-quoted JSON-escaped strings by calling mg_print_esc() through the macro MG_ESC().