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 https://github.com/cesanta/mongoose/tree/master/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. With RESTful, requests are either pipelined or require multiple connections.

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.

Build and test

Assuming we're on a Mac or Linux workstation, start a new terminal and execute the following to build and start the example:

  • If you've not already done so, clone the Mongoose Library repo
    $ git clone https://github.com/cesanta/mongoose
    
  • Build the example, this will also start our server:
    $ cd mongoose/examples/json-rpc-over-websocket
    $ make clean all
    
  • 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. In the event log, you can see the response frames. 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
  • Notice the log messages on the terminal window that runs the server:
    2021-11-23 10:05:06 2 main.c:52:fn               [{"id":0,"method":"sum","params":[1,2]}] -> [{"id":0,"result":3}]
    2021-11-23 10:05:09 2 main.c:52:fn               [{"id":1,"method":"mul","params":[2,3]}] -> [{"id":1,"result":6}]
    

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 server side is a bit more involved, as we have to parse the JSON-RPC requests and handle the different methods we support.

The server initialization is similar to what we've seen on the WebSocket server tutorial.

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_mprintf(). It is similar to printf(), though it returns an allocated memory buffer, which we free at the end of the timer callback handler. Here we use a convenient extension to the syntax, in which the identifier Q is used to print double-quoted JSON-escaped strings.

The event handler is also 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->label in order to let the timer function know who should be notified. Finally, when a WebSocket frame is received from a client, we call our local function process_json_message(), which will parse the JSON-RPC message and call the requested method handler function. This will in turn parse the parameters and create a result, which process_json_message() will turn into a full response and send it back to the client.

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.

JSON-RPC processor

Once we got ourselves a JSON message over our WebSocket connection, we will proceed to parse it. Our request messages have this format:

{"id": 12, "method": "sum", "params": [2,3]}

We first search for the method, get the id, and the parameter list.

Then we validate the format and return an error indication on failure.

If everything looks OK, we compare the requested method to those we support, and if found we call its respective function handler, that will return a string. We then frame that string into a response JSON message and send it over the WebSocket connection. On exit, we free the returned string buffers.

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

We extract each parameter from the parameter list, and return the result as a string that will be freed by the caller.