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 https://github.com/cesanta/mongoose/tree/master/tutorials/websocket/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 optionalparams
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/tutorials/websocket/json-rpc-over-websocket
make
Start your browser on http://localhost:8000
and you should see
a simple 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:
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.
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()
.