I’ve found myself in need of a JSON-RPC implementation for a project I’m currently working on in C++. Unfortunately I couldn’t find any existing solutions that met my needs. Most of them implement far too many features, don’t take advantage of modern C++ features, and pull in a ton of dependencies. So I’ve decided to make my own, and document the process.

Overview

In order to make this work, I set my end goal as being able to send and receive JSON-RPC 2.0 messages. Rather than use HTTP for this, I am opting to use plain TCP sockets. To make the library actually useful, I need an easy way to bind functions to the RPC system, and that’s where I am going to start. As a sidenote, I am far from an expert at template programming but I refined it as much as I could.

As far as what I am using to actually make the function wrapper, I will be using JSON for Modern C++ and C++17 features such as if constexpr.

The function wrapper should produce an invokable object that will accept a JSON object containing the function paramaters, and return the normal return type of the function. It should have nothing to with routing, response generation, or message processing.

Class structure

Let’s look at the skeleton of the class and break it down from there

template<typename T, typename... Args>
class RPCMethod {

// typedefs
typedef nlohmann::json json;
typedef std::tuple<typename std::decay<Args>::type...> ttype;
typedef std::tuple<std::optional<typename std::decay<Args>::type>...> defaults_tup_type;

public:

    RPCMethod(
            std::function<T(Args...)> fn,
            std::array<std::string, sizeof...(Args)> param_names = {},
            defaults_tup_type param_defaults = defaults_tup_type()
    ) :
            param_names_(std::move(param_names)),
            function_(fn),
            defaults_(param_defaults)
    {}

    T operator() (const json &params) {...}

private:
    /// Unpack params from json into tuple
    ttype unpack_params_(const json &params) {...};

    template<size_t I = 0>
    ttype unpack_params_(const json &params, ttype unpacked) {...};

    /// handle individual parameters
    template <typename U>
    U param_handler_(std::optional<U> &fallback, const json &params, const std::string &param_name) {...}

    std::function<T(Args...)> function_;
    std::array<std::string, sizeof...(Args)> param_names_;
    defaults_tup_type defaults_;
};

We can see that our template class defines the function signature with T as our return and Args as our function parameters. Next if we look at the typedefs, we have ttype as the tuple type which is applied to the function. After that we have defaults_tup_type which is the same as ttype except all the constituent types are std::optional. The defaults type is how we define fallbacks if a parameter is missing, that way we can greatly simplify our RPC and reduce API breakages. We want it to fall back on a default as long as it isn’t std::nullopt.

If we look at the constructor, you will see where we pass in our function, an array of parameter names, and the default paramaters. Other than that, our constructor doesn’t do anything.

Here’s an example of how you’d use it:

int add(int lhs, int rhs) { return lhs+rhs }
auto add_wrapper = RPCMethod<int,int,int>(add, {"lhs","rhs"}, {0,std::nullopt});

assert(add_wrapper({{"lhs",2},{"rhs",3}}) == 5); // succeeds
assert(add_wrapper({{"lhs",2}}) == 2); // succeeds
assert(add_wrapper({{"rhs",3}}) == 3); // throws missing parameter exception

() Operator

So without further ado, let’s look at at the operator() function.

T operator() (const json &params) {
		// check if arg is a single json object for custom handling
		if constexpr (sizeof...(Args) == 1 &&
					  std::is_same_v<std::tuple_element_t<0, ttype>, json>)
		{ return function_(params); }

		ttype tup = unpack_params_(params); // unpacka params from params into tup
		return std::apply(function_, tup); // call the wrapped function
}

As you can see, I’ve implemented a special case here. Sometimes we might want a function to have more specialized handling of parameters, so if there is only one parameter, and that parameter is a json object, the entire params object is passed to it.

After checking for that case, it creates a ttype tuple and unpacks the params json object into it, then calls the function with those arguments using std::apply.

You can see that it’s starting to take shape now. The last thing to go over is parameter unpacking functions.

Parameter unpacking

In order to unpack the parameters, we basically need to take the json params object and load it into a tuple, performing all the necessary conversions so that the function can be called. Let’s look at the implementation:

ttype unpack_params_(const json &params) {
    return unpack_params_(params, ttype());
};

template<size_t I = 0>
ttype unpack_params_(const json &params, ttype unpacked) {
    if constexpr (I == sizeof...(Args)) {
        return std::move(unpacked);
    } else {
        std::get<I>(unpacked) = param_handler_(std::get<I>(defaults_), params, param_names_[I]);
        return std::move(unpack_params_<I + 1>(params, unpacked));
    }
};

As with much template programming trickery, we use a recursive function to load the tuple. The base case can be seen in the ttype unpack_params_ method. It calls the templated version with an empty tuple, which then recurses until it hits the arg count. The most important thing to note here is the constexpr if which allows us to avoid having to do any sort of specializations for the final case.

You can also see that each step calls a separate param_handler_ function. Let’s move on to that.

Param handler

The param handler looks through our params json for a parameter with param_name, then converts it to U and returns it.

template <typename U>
U param_handler_(std::optional<U> &fallback, const json &params, const std::string &param_name) {
    try {
        auto val = params.find(param_name);
        if (val == params.end()) {
            if (fallback.has_value()) {
                return fallback.value();
            } else {
                throw RPCInvalidParamsException(fmt::format("Missing required parameter: {}", param_name));
            }
        }
        try {
            if constexpr (std::is_same_v<U, nlohmann::basic_json<>>) {
                return val.value();
            } else {
                return val.value().get<U>();
            }
        } catch (const std::exception &e) {
            throw RPCInvalidParamsException(fmt::format("Invalid parameter: {}", param_name));
        }
    } catch (const json::type_error &e) {
        throw RPCInvalidParamsException("Invalid parameters object");
    }
}

You can see we check if the value exists in params and if not, we check the fallback. If there is no fallback, then we throw an exception so that the message handler knows how the call failed. We also need to implement special logic for json arguments and other types, as the get function in the JSON library can’t convert to itself.

The most complex part of this is the error handling, as in order to implement the JSON-RPC protocol, we need to be able to get information back on how exactly a call failed. I created RPCInvalidParamsException for this, derived from std::runtime_error, that way we can catch it specifically for the purposes of correctly generating error messages for the RPC protocol.

And with that, we have a functioning wrapper. At the end of this series I will put a link to the final code, so stay tuned.