Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

json_rpc is a library for routing JSON 2.0 format remote procedure calls over different transports, implementing the JSON-RPC standard. It is designed to automatically generate marshalling and parameter checking code based on the RPC parameter types.

Installation

As a nimble dependency:

requires "json_rpc"

Via nimble install:

nimble install json_rpc

Use cases

If you're new to JSON-RPC and/or json_rpc, check out our brief overview. The rest of the documentation is organized around use cases:

  1. Establishing a JSON-RPC connection
  2. Receiving a JSON-RPC request
  3. Sending a JSON-RPC request
  4. Adding Remote RPC Targets
  5. Throwing and handling exceptions
  6. Format conversion
  7. Testability

The cookbook section contains the functional code examples used throughout the docs.

Overview

Protocol overview

JSON-RPC is a two-party, peer-to-peer based protocol by which one party can request a method's invocation of the other party, and optionally receive a response from the server with the result of that invocation.

Either peer may send RPC requests to the other peer. Both acting as server and client at the same time.

A common pattern is that one peer sends most of the RPC requests, while the other peer only occasionally sends requests back to the client to deliver notifications. This pattern is common in many applications because of how they are structured, not because of the JSON-RPC protocol itself or because of how this library implements it.

json_rpc's role

json_rpc is a Nim library that implements the JSON-RPC protocol to easily send and receive RPC requests. It works on any transport (e.g. HTTP, Sockets, WebSocket). It is designed to automatically generate marshalling and parameter checking code based on the RPC parameter types.

Security

The fundamental feature of the JSON-RPC protocol is the ability to request code execution of another party, including passing data either direction that may influence code execution. Neither the JSON-RPC protocol nor this library attempts to address the applicable security risks entailed.

Before establishing a JSON-RPC connection with a party that exists outside your own trust boundary, consider the threats and how to mitigate them at your application level.

Establishing a JSON-RPC connection

Transports

A JSON-RPC connection communicates over an existing transport, such as HTTP, Sockets and pipes, and Websockets:

  • HTTP POST: unidirectional, one request/response pair per call.
  • Sockets and pipes, via chronos' StreamTransport: bidirectional, persistent connection, custom message framing.
    • Framing.httpHeader: Content-Length prefix specifying the length of the payload, compatible with vscode-jsonrpc.
    • Framing.lengthHeaderBE32: Big-endian, 32-bit binary prefix - most efficient option.
  • Websockets: bidirectional, persistent connection.

Server (and possibly client also)

Create the server instance using one of the available transports:

HTTP:

let srv = newRpcHttpServer(["127.0.0.1:0"])

Sockets:

const framing = Framing.lengthHeaderBE32()
let srv = newRpcSocketServer(["127.0.0.1:0"], framing = framing)

Websockets:

let srv = newRpcWebSocketServer("127.0.0.1", Port(0))

After registering the RPC methods, the server can start serving clients:

srv.start()

Then usually runForever() or waitFor a program termination signal waitSignal(SIGINT). This will run the Chronos async event loop until the program is terminated.

Client

Create the client instance using one of the available transports:

HTTP:

let client = newRpcHttpClient()
await client.connect("http://" & $srv.localAddress()[0])

Sockets:

const framing = Framing.lengthHeaderBE32()
let client = newRpcSocketClient(framing = framing)
await client.connect(srv.localAddress()[0])

Websockets:

let client = newRpcWebSocketClient()
await client.connect("ws://" & $srv.localAddress())

You can then proceed to send requests.

Disconnecting

Close the client connection:

await client.close()

Stop the RPC server and clean-up resources:

await srv.stop()
await srv.closeWait()

Receiving a JSON-RPC request

Before receiving any request, you should have already established a connection.

When a request is received, the router matches it to a server method that was previously registered with a matching name. If no matching server method can be found the request is dropped, and an error is returned to the client if the client requested a response.

When an RPC-invoked server method throws an exception, the server will handle the exception and (when applicable) send an error response to the client with a description of the failure.

JSON-RPC is an inherently asynchronous protocol. Multiple concurrent requests are allowed. Methods are invoked as the requests are processed, even while prior requests are still running.

Registering methods

The rpc macro accepts a list of proc definitions which are turned into async procedures and registered as RPC methods. Procedure overload is not supported. A format flavor supporting the parameters and return type must be set. The RpcConv defined in the flavors section is used in the following example:

srv.rpc(RpcConv):
  proc hello(input: string): string =
    "Hello " & input

When named parameters are used, serializedFieldName can be used to customize the field name:

proc bye(input {.serializedFieldName: "user-name".}: string): string =
  "Bye " & input

Wrapping the method name in backticks allows any character:

proc `🙂`(input: string): string =
  "🙂 " & input

When the procedure return type is not specified, JsonNode is implicitly used. To avoid returning a result, void can be used instead:

proc empty(): void =
  echo "nothing"

Compiling with -d:nimDumpRpcs will show the output code for the RPC call. To see the output of the async generation, add -d:nimDumpAsync.

Parameter name and placement

RPC servers should consider their methods as public API that requires stability. The following changes to a method's signature can be considered breaking:

  • Renaming parameters will break clients that pass parameter by name.
  • Reordering parameters will break clients that pass parameter by position.
  • Removing parameters.
  • Removing a method.
  • Adding non-optional parameters.

The following changes to a method's signature can be considered non-breaking:

  • Adding optional parameters as last parameter.
  • Changing the parameter type, if it remains compatible with the wire format representation for the value.

Throwing exceptions

RPC methods can return errors to the client by throwing an exception.

Learn more about throwing and handling exceptions.

Sending a JSON-RPC request

Before sending any request, you should have already established a connection.

Requests and notifications

Within the JSON-RPC specification, a client request can specify whether a reply from the server is required. If no reply is expected, the message is a notification. Because notifications do not generate responses, the client receives no confirmation that the server received the request, processed it successfully, encountered an error, or produced any output.

A notification should not be used just because a method does not return a value. Even when a method has no return data, using the standard request-response model ensures the client can detect failures or execution errors on the server side.

Argument arrays vs. an argument object

The JSON-RPC protocol passes arguments from client to server using either an array or as a single JSON object with a property for each parameter on the target method. Essentially, this leads to argument-to-parameter matching by position or by name.

Most JSON-RPC servers expect an array. json_rpc supports passing arguments both as a parameter object and in an array.

Invoking methods using compile-time definitions

The createRpcSigsFromNim macro accepts a list of forward procedure declarations and it generates the client RPCs. The RpcConv defined in the flavors section is used in the following example:

createRpcSigsFromNim(RpcClient, RpcConv):
  proc hello(input: string): string

Wrapping the method name in backticks allows any character:

proc `🙂`(input: string): string

The RPC method can be invoked using a client instance with a stablished connection:

let resp1 = await client.hello("Daisy")

The createRpcSigs macro accepts the path of a file containing a list of forward proc declarations and it generates the client RPCs of it:

const sigsFilePath = currentSourcePath().parentDir / "client_sigs.nim1"
createRpcSigs(RpcClient, sigsFilePath, RpcConv)

The createSingleRpcSig macro accepts a single forward proc declaration and an alias. The alias can be used to invoke the RPC method:

createSingleRpcSig(RpcClient, "sayBye", RpcConv):
  proc bye(input: string): string

The createRpcSigsFromString macro accepts a string containing a list of forward proc declarations and it generates the client RPCs:

const rpcClientDefs = staticRead(sigsFilePath)
createRpcSigsFromString(RpcClient, rpcClientDefs, RpcConv)

Invoking methods using runtime information

An RPC method can be invoked passing its name an parameter types at runtime. The parameter must be passed as a JsonNode or RequestParamsTx. The RpcConv defined in the flavors section is used in the following example:

let resp2 = await client.call("hello", %* ["Daisy"], RpcConv)

Using named parameters is allowed. Some server implementations may support only positional parameters, json_rpc supports both styles:

let resp3 = await client.call("hello", %* {"input": "Daisy"}, RpcConv)

When the method doesn't take parameters, it can be invoked passing an empty array:

let resp4 = await client.call("justHello", %* [], RpcConv)

The response from call is a JsonString which can be decoded using json_serialization:

doAssert RpcConv.decode(resp2, string) == "Hello Daisy"

Sending batch requests

The JSON-RPC specification allows for batching requests and getting a response containing an array of responses for each request.

The prepareBatch client function can be used to batch requests and send them all at once:

let batch = client.prepareBatch()
batch.hello("Daisy")
batch.`🙂`("Daisy")
let batchRes = await batch.send()

The send return value is an optional result with either the sequence of RPC responses, or an error indicating there was an error processing the array of responses. Each response contains a result or an error. The result field is the JSON encoded RPC result. If the optional error field is set, it'll contain either an error message or the JSON encoded RPC error response:

let r = batchRes.tryGet()
doAssert r[0].error.isNone
doAssert RpcConv.decode(r[0].result, string) == "Hello Daisy"
doAssert r[1].error.isNone
doAssert RpcConv.decode(r[1].result, string) == "🙂 Daisy"

Sending a notification

A notification can be sent for fire and forget method invocations. As mentioned earlier, the method response is not returned, and the client is not notified about server errors:

await client.notify("empty", RequestParamsTx())

Exception handling

RPC methods may throw exceptions. The RPC client should be prepared to handle these exceptions.

Learn more about throwing and handling exceptions.

Adding Remote RPC Targets

There are scenarios where users may need to add remote RPC targets to facilitate communication between two endpoints that have no direct RPC connection channel. Consider the following 3 endpoints:

  • client
  • server
  • remote

There is a direct RPC connection between client and server, and server and remote. However, client and remote may need to send messages to each other as well. To do so, users can use an RPC proxy server.

Create a proxy server

The proxy server only supports HTTP to serve clients. It supports HTTP and Websockets to connect to the remote server:

HTTP:

var proxy = RpcProxy.new(["127.0.0.1:0"], getHttpClientConfig(srvUrl))

Websockets:

var proxy = RpcProxy.new(["127.0.0.1:0"], getWebSocketClientConfig("ws://" & $srv.localAddress()))

Registering remote target methods

The client can only make a call to the remote endpoint if the proxy (what's in the middle) has registered the remote RPC method:

proxy.registerProxyMethod("hello")

Registering methods

The proxy server can register its own RPC methods:

proxy.rpc(RpcConv):
  proc bye(input: string): string =
    "Proxy Bye " & input

Start the proxy server

After registering the RPC methods, the server can start serving clients:

await proxy.start()

Then usually runForever() or waitFor a program termination signal waitSignal(SIGINT). This will run the Chronos async event loop until the program is terminated.

Throwing and handling exceptions

The JSON-RPC protocol allows for server methods to return errors to the client instead of a result, except when the client invoked the method as a notification.

The structure JSON-RPC defines for errors includes an error code and a message.

Error codes -32768 to -32000 are reserved for the protocol itself or for the library that implements it. The rest of the 32-bit integer range of the error code is available for the application to define. This error code is the best way for an RPC server to communicate a particular kind of error that the RPC client may use for controlling execution flow. For example the server may use an error code to indicate a conflict and another code to indicate a permission denied error. The client may check this error code and branch execution based on its value.

The error message should be a localized, human readable message that explains the problem, possibly to the programmer of the RPC client or perhaps to the end user of the application.

JSON-RPC also allows for an error data property which may be a primitive value, array or object that provides more data regarding the error. The schema for this property is up to the application.

Server-side concerns

The RPC server can return errors to the client by throwing an exception from the RPC method. If the RPC method was invoked using a JSON-RPC notification, the client is not expecting any response and the exception thrown from the server will be swallowed.

The RPC method can raise an ApplicationError with a specific code, data, and msg (message). Any other exception thrown from an RPC method is assigned -32000 (Server error) for the JSON-RPC error code property. The exception msg field is used as the JSON-RPC error data property.

RPC method error example:

proc teaPot(): void =
  raise (ref ApplicationError)(
    code: 418, data: Opt.none(JsonString), msg: "I'm a teapot"
  )

Client-side concerns

An invocation of an RPC method may throw several exceptions back at the client. The base exception JsonRpcError can be used to catch all RPC exceptions.

These are the exceptions which the client should be prepared to handle: RpcTransportError, InvalidResponse, RequestDecodeError, and JsonRpcError.

The JSON error object is assigned to the msg field of JsonRpcError, when it does not match the rest of exceptions.

Error handling example:

try:
  discard await client.teaPot()
  doAssert false
except JsonRpcError as err:
  doAssert err.msg == """{"code":418,"message":"I'm a teapot"}"""

Format conversion

The conversion to and from JSON is done using a nim-json-serialization format. For each type used in the RPC method, a serialization declaration tells json_rpc how to convert it to JSON, either using defaults or by overriding readValue and writeValue.

json_rpc will recursively parse the Nim types in order to produce marshalling code. This marshalling code uses the types to check the incoming JSON fields to ensure they exist and are of the correct kind.

The return type then performs the opposite process, converting Nim types to JSON for transport.

Creating a JSON flavor

The createJsonFlavor API accepts a flavor name and serialization options. The flavor can be passed to RPC method APIs and it will be used to convert the parameters and return value. In the following example the flavor is named RpcConv:

createJsonFlavor RpcConv,
  automaticObjectSerialization = false,
  automaticPrimitivesSerialization = true,
  requireAllFields = false,
  omitOptionalFields = true, # Skip optional fields==none in Writer
  allowUnknownFields = true,
  skipNullFields = true # Skip optional fields==null in Reader

In the above configuration automatic object serialization is disabled. Enabling the default serialization for a given object can be done with RpcConv.useDefaultSerializationFor(MyObject). This is to avoid unintentionally using the default for objects that define a custom serializer.

Custom type serialization

It is possible to provide a custom serializer for a given type creating writeValue and readValue functions.

Learn more about serialization in the nim-json-serialization documentation.

Testability

A server can be tested directly without starting it:

let resp = await srv.executeMethod("hello", %* ["Daisy"], RpcConv)
doAssert RpcConv.decode(resp, string) == "Hello Daisy"

API reference

Note

Private modules under json_rpc/private/* are not to be imported. The exported symbols are subject to change from one version to the next, unless exported by a public module. They are only included here for documentation purposes.

The main public modules are json_rpc/[rpcserver, rpcclient, rpcproxy].

Auto generated API documentation:

JSON Format

# rpc_format.nim

{.push raises: [], gcsafe.}

import
  json_serialization

export
  json_serialization

createJsonFlavor RpcConv,
  automaticObjectSerialization = false,
  automaticPrimitivesSerialization = true,
  requireAllFields = false,
  omitOptionalFields = true, # Skip optional fields==none in Writer
  allowUnknownFields = true,
  skipNullFields = true # Skip optional fields==null in Reader

HTTP Server

# http_server.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcserver
import ./rpc_format

export rpcserver

proc setupServer(srv: RpcServer) =
  srv.rpc(RpcConv):
    proc hello(input: string): string =
      "Hello " & input

    proc bye(input {.serializedFieldName: "user-name".}: string): string =
      "Bye " & input

    proc `🙂`(input: string): string =
      "🙂 " & input

    proc empty(): void =
      echo "nothing"

    proc justHello(): string =
      "Hello"

    proc teaPot(): void =
      raise (ref ApplicationError)(
        code: 418, data: Opt.none(JsonString), msg: "I'm a teapot"
      )

proc startServer*(): RpcHttpServer {.raises: [JsonRpcError].} =
  let srv = newRpcHttpServer(["127.0.0.1:0"])
  srv.setupServer()
  srv.start()
  srv

proc stopServer*(srv: RpcHttpServer) {.async.} =
  await srv.stop()
  await srv.closeWait()

proc main() {.raises: [JsonRpcError].} =
  let srv = startServer()
  runForever()

# Pass -d:jsonRpcExample to nim to run this
when defined(jsonRpcExample):
  main()

HTTP Client

# http_client.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcclient
import ./[rpc_format, http_server]

createRpcSigsFromNim(RpcClient, RpcConv):
  proc hello(input: string): string
  proc bye(input: string): string
  proc `🙂`(input: string): string
  proc empty()
  proc justHello(): string
  proc teaPot()

proc main() {.async.} =
  let srv = startServer()
  defer:
    await srv.stopServer()

  let client = newRpcHttpClient()
  await client.connect("http://" & $srv.localAddress()[0])
  defer:
    await client.close()

  let resp1 = await client.hello("Daisy")
  doAssert resp1 == "Hello Daisy"

  let resp2 = await client.call("hello", %* ["Daisy"], RpcConv)
  doAssert RpcConv.decode(resp2, string) == "Hello Daisy"

  let resp3 = await client.call("hello", %* {"input": "Daisy"}, RpcConv)
  doAssert RpcConv.decode(resp3, string) == "Hello Daisy"

  let resp4 = await client.call("justHello", %* [], RpcConv)
  doAssert RpcConv.decode(resp4, string) == "Hello"

  let resp5 = await client.bye("Daisy")
  doAssert resp5 == "Bye Daisy"

  let resp6 = await client.call("bye", %* {"user-name": "Daisy"}, RpcConv)
  doAssert RpcConv.decode(resp6, string) == "Bye Daisy"

  let resp7 = await client.`🙂`("Daisy")
  doAssert resp7 == "🙂 Daisy"

  let batch = client.prepareBatch()
  batch.hello("Daisy")
  batch.`🙂`("Daisy")
  let batchRes = await batch.send()
  let r = batchRes.tryGet()
  doAssert r[0].error.isNone
  doAssert RpcConv.decode(r[0].result, string) == "Hello Daisy"
  doAssert r[1].error.isNone
  doAssert RpcConv.decode(r[1].result, string) == "🙂 Daisy"

  await client.notify("empty", RequestParamsTx())

  try:
    discard await client.teaPot()
    doAssert false
  except JsonRpcError as err:
    doAssert err.msg == """{"code":418,"message":"I'm a teapot"}"""

when isMainModule:
  waitFor main()
  echo "ok"

Socket server

# socket_server.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcserver
import ./rpc_format

export rpcserver

proc setupServer(srv: RpcServer) =
  srv.rpc(RpcConv):
    proc hello(input: string): string =
      "Hello " & input

proc startServer*(): RpcSocketServer {.raises: [JsonRpcError].} =
  const framing = Framing.lengthHeaderBE32()
  let srv = newRpcSocketServer(["127.0.0.1:0"], framing = framing)
  srv.setupServer()
  srv.start()
  srv

proc stopServer*(srv: RpcSocketServer) {.async.} =
  srv.stop()
  await srv.closeWait()

proc main() {.raises: [JsonRpcError].} =
  let srv = startServer()
  runForever()

# Pass -d:jsonRpcExample to nim to run this
when defined(jsonRpcExample):
  main()

Socket client

# socket_client.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcclient
import ./[rpc_format, socket_server]

createRpcSigsFromNim(RpcClient, RpcConv):
  proc hello(input: string): string

proc main() {.async.} =
  let srv = startServer()
  defer: await srv.stopServer()

  const framing = Framing.lengthHeaderBE32()
  let client = newRpcSocketClient(framing = framing)
  await client.connect(srv.localAddress()[0])
  defer: await client.close()

  let resp1 = await client.hello("Daisy")
  doAssert resp1 == "Hello Daisy"

when isMainModule:
  waitFor main()
  echo "ok"

Websocket server

# websocket_server.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcserver
import ./rpc_format

export rpcserver

proc setupServer(srv: RpcServer) =
  srv.rpc(RpcConv):
    proc hello(input: string): string =
      "Hello " & input

proc startServer*(): RpcWebSocketServer {.raises: [JsonRpcError].} =
  let srv = newRpcWebSocketServer("127.0.0.1", Port(0))
  srv.setupServer()
  srv.start()
  srv

proc stopServer*(srv: RpcWebSocketServer) {.async.} =
  srv.stop()
  await srv.closeWait()

proc main() {.raises: [JsonRpcError].} =
  let srv = startServer()
  runForever()

# Pass -d:jsonRpcExample to nim to run this
when defined(jsonRpcExample):
  main()

Websocket client

# websocket_client.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcclient
import ./[rpc_format, websocket_server]

createRpcSigsFromNim(RpcClient, RpcConv):
  proc hello(input: string): string

proc main() {.async.} =
  let srv = startServer()
  defer: await srv.stopServer()

  let client = newRpcWebSocketClient()
  await client.connect("ws://" & $srv.localAddress())
  defer: await client.close()

  let resp1 = await client.hello("Daisy")
  doAssert resp1 == "Hello Daisy"

when isMainModule:
  waitFor main()
  echo "ok"

Proxy server

# proxy_server.nim

{.push gcsafe, raises: [].}

import json_rpc/[rpcserver, rpcproxy]
import ./rpc_format

export rpcproxy

proc setupServer(proxy: var RpcProxy) =
  proxy.registerProxyMethod("hello")

  proxy.rpc(RpcConv):
    proc bye(input: string): string =
      "Proxy Bye " & input

proc startProxy*(srvUrl: string): Future[RpcProxy] {.async.} =
  var proxy = RpcProxy.new(["127.0.0.1:0"], getHttpClientConfig(srvUrl))
  proxy.setupServer()
  await proxy.start()
  proxy

proc stopProxy*(proxy: RpcProxy) {.async.} =
  await proxy.stop()
  await proxy.closeWait()

proc main() {.raises: [CatchableError].} =
  # Compile with -d:srvUrl="http://hostname:port"
  const srvUrl {.strdefine.}: string = ""
  let proxy = waitFor startProxy(srvUrl)
  runForever()

# Compile with -d:jsonRpcExample to run this
when defined(jsonRpcExample):
  main()

Proxy client

# proxy_client.nim

{.push gcsafe, raises: [].}

import json_rpc/rpcclient
import ./[rpc_format, http_server, proxy_server]

createRpcSigsFromNim(RpcClient, RpcConv):
  proc hello(input: string): string
  proc bye(input: string): string

proc main() {.async.} =
  let srv = startServer()
  defer: await srv.stopServer()

  let proxy = await startProxy("http://" & $srv.localAddress()[0])
  defer: await proxy.stopProxy()

  let client = newRpcHttpClient()
  await client.connect("http://" & $proxy.localAddress()[0])
  defer: await client.close()

  let resp1 = await client.hello("Daisy")
  doAssert resp1 == "Hello Daisy"
  let resp2 = await client.bye("Daisy")
  doAssert resp2 == "Proxy Bye Daisy"

when isMainModule:
  waitFor main()
  echo "ok"

Updating this book

This book is built using mdBook, which in turn requires a recent version of rust and cargo installed.

# Install correct versions of tooling
nimble mdbook

# Run a local mdbook server
mdbook serve docs

A CI job automatically published the book to GitHub Pages.