JSON-RPC.NET

Serializers: how they plug in and how they are configured#

JSON-RPC.Net 2.0 splits the work in two. The core owns the JSON-RPC envelope: it finds method, params and id, resolves the method, binds parameters, invokes, and writes {"jsonrpc":"2.0","result":…,"id":…} or the error object. A serializer owns values only: it turns the raw bytes of one JSON value into a CLR value, and a CLR value into JSON bytes. Nothing from a JSON library leaks into the core, so Json.NET, System.Text.Json and the built-in serializer plug into the same slot and each ships as its own package.

Package Serializer Default? Notes
AustinHarris.JsonRpc Jsmn.JsmnSerializer yes no JSON library; span port of the jsmn tokenizer (MIT, Serge A. Zaitsev) plus a reflection mapper with cached type plans; primitives and Nullable<T> bind without boxing
AustinHarris.JsonRpc.Newtonsoft Newtonsoft.NewtonsoftJsonRpcSerializer Json.NET 13; lenient input; honours JsonSerializerSettings; the compatibility choice for code that relied on Json.NET behaviour
AustinHarris.JsonRpc.SystemTextJson SystemTextJson.SystemTextJsonRpcSerializer Utf8JsonReader/Utf8JsonWriter directly on the request bytes; honours JsonSerializerOptions

The contract#

public abstract class JsonRpcSerializer
{
    public abstract string Name { get; }
    public virtual bool Lenient => false;                       // envelope reader accepts 'single quotes', bare keys, trailing commas
    public virtual int MaxDepth => 64;                          // nesting limit enforced by the envelope reader
    public virtual JsonRpcRequestReader CreateReader();          // envelope cursor; the base implementation uses the jsmn tokenizer
    public abstract T Read<T>(ReadOnlySpan<byte> utf8Json);      // exactly one JSON value in, T out
    public abstract object Read(ReadOnlySpan<byte> utf8Json, Type type);
    public abstract void Write<T>(IBufferWriter<byte> output, T value);
    public abstract void Write(IBufferWriter<byte> output, object value, Type type);
    // string adapters: Deserialize<T>(string), Serialize<T>(T) – transcoding conveniences
}

JsonRpcRequestReader is the envelope cursor. It parses a document once and hands the core slices of the request (MethodUtf8, IdRaw, ParamRaw(i), ParamNameUtf8(i)), so nothing is materialised until a parameter is bound. Serializers may override CreateReader() with their own scanner, but the default reader already runs at the speed of the tokenizer and calls back into the serializer’s Read<T> for each parameter.

Compiled invokers (one expression tree per registered method) call reader.ReadParam<T>(i) per parameter and serializer.Write<T>(output, result) for the return value. Nothing is boxed on that path; object[] and DynamicInvoke are gone.

Choosing a serializer: three levels#

Resolution order for every call is: per-call argument → session → process-wide → built-in.

// 1. Process-wide default (volatile, takes effect for subsequent calls)
Config.SetSerializer(new SystemTextJsonRpcSerializer());
Config.Serializer = null;                      // back to the built-in serializer

// 2. Per session (a Handler is a session)
Handler.GetSessionHandler("legacy-clients").Serializer = new NewtonsoftJsonRpcSerializer(settings);
Config.SetSerializer("legacy-clients", serializer);   // same thing

// 3. Per call (transport decides; wins over both)
string json = JsonRpcProcessor.ProcessSync(sessionId, request, context, serializer);
JsonRpcProcessor.Process(sessionId, requestBytes, output, context, serializer);

Use per-session when different endpoints of one process serve different clients (a strict System.Text.Json API next to a lenient Json.NET one for old clients; the AspNetCore package’s MapJsonRpc(pattern, options) overload maps one endpoint per session). Use per-call when the transport negotiates it (a header, a route, a protocol version). The process-wide default is for the common case of one serializer everywhere. This order applies to the serializer only; error and processing handlers are per session, see the API reference’s Configuration table.

Construct a serializer once and share it: serializers must be thread-safe. The synchronous fast path keeps an envelope reader in per-thread scratch storage, reused while the serializer instance stays the same (a re-entrant call gets its own scratch instance), so a new serializer per call throws that reuse away. ProcessAsync takes readers from a bounded, transferable pool instead.

If you write your own reader (CreateReader()): under ProcessAsync a reader can be handed between threads, and Release can run on a continuation thread after the call has finished. Keep the selected request and its backing memory valid until Release. Parameter reads and result writes are still synchronous, and only the service method is awaited, so no span is held across an await.

Library-specific options#

Each package accepts its own library’s options in its constructor and nowhere else:

Serializer Options type Constructor Notes
built-in bool lenient, int maxDepth new JsmnSerializer(lenient: true, maxDepth: 64) lenient accepts 'single quotes', unquoted keys and trailing commas in the request; maxDepth bounds nesting (default 64)
Json.NET JsonSerializerSettings new NewtonsoftJsonRpcSerializer(settings) one JsonSerializer is created from the settings and reused; Lenient is always on
System.Text.Json JsonSerializerOptions new SystemTextJsonRpcSerializer(options) the package adds its wire-format converters (see below) to a copy of your options when they are missing

Nesting depth#

Every serializer exposes MaxDepth (virtual on JsonRpcSerializer, default 64). The envelope reader rejects a request deeper than that with -32700 before any handler or binding runs, so recursive parameter conversion is bounded by the same number the JSON library itself enforces: for the built-in serializer it is the constructor argument, for System.Text.Json it is JsonSerializerOptions.MaxDepth and for Json.NET it is JsonSerializerSettings.MaxDepth (both 64 when unset). The root object and the params container each count as one level.

What the core decides and what the serializer decides#

The core decides (the same for every serializer)#

The serializer decides#

The three serializers align the envelope, primitives, dates and plain objects, and the test suite runs its protocol cases against each of them. They are not behaviour-identical: accepted coercions, supported CLR types, object models and custom options differ, so test client-visible requests and responses before changing serializers.

Bytes in, bytes out#

The native entry points take what a PipeReader gives you and write to what a PipeWriter or HttpResponse.BodyWriter is:

void Process(string sessionId, in ReadOnlySequence<byte> request,
    IBufferWriter<byte> output, object context = null, JsonRpcSerializer serializer = null);

void Process(string sessionId, ReadOnlyMemory<byte> request,
    IBufferWriter<byte> output, object context = null, JsonRpcSerializer serializer = null);

void Process(string sessionId, ReadOnlySpan<byte> request,
    IBufferWriter<byte> output, object context = null, JsonRpcSerializer serializer = null);

Nothing is written for a notification. Responses are rendered into a per-thread pooled buffer (so a half-written result can be discarded when a method throws) and copied once into output. JsonFramer.TryReadDocument slices complete documents out of a pipe buffer for raw-connection transports. The string overloads (ProcessSync, Task<string> Process) transcode into the same pooled buffers at the edge.

Upgrading from 1.x#

The core no longer references Json.NET, so the 1.x JsonSerializerSettings parameter on JsonRpcProcessor.Process* is gone. Replace it with Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings)), or use the helper overloads in the Newtonsoft package, which build that serializer and forward to the core.

Two things are specific to serializers: a custom serializer’s own conversion exceptions are recognised as “the client’s value is wrong” without any change to it, and it may throw JsonRpcBindException to say the same explicitly; and a type the built-in serializer does not support, or a class without a parameterless constructor, throws NotSupportedException from Read (it was JsonRpcBindException) and stays -32603 on the wire.

For the complete list of protocol, error, batching, async, context and metadata changes, see Upgrading from 1.x.