diff --git a/.github/workflows/build_publish_master.yml b/.github/workflows/build_publish_master.yml
index 1a7fe10..5c6cebf 100644
--- a/.github/workflows/build_publish_master.yml
+++ b/.github/workflows/build_publish_master.yml
@@ -8,26 +8,29 @@ on:
jobs:
build:
- strategy:
- matrix:
- os: ['ubuntu-latest']
- dotnet-version: ['3.1.201']
- project : ['Json-Rpc']
-
- runs-on: ${{ matrix.os }}
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Setup .NET Core
- uses: actions/setup-dotnet@v1
+ - uses: actions/checkout@v7
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v6
with:
- dotnet-version: ${{matrix.dotnet-version}}
+ # global.json pins the SDK; the 8.0 runtime is needed for the net8.0 test target.
+ global-json-file: global.json
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
- name: Install dependencies
- run: dotnet restore
+ run: dotnet restore AustinHarris.JsonRpc.sln
+ # Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the
+ # solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build.
- name: Build
- run: dotnet build ${{matrix.project}} --configuration Release
+ run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore
- name: Test
- run: dotnet test AustinHarris.JsonRpcTestN
- # Publish
+ run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build
+ # Publish all four packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core).
- name: publish nuget version change
- run: dotnet nuget push Json-Rpc/bin/Release/*.nupkg --skip-duplicate --source "https://www.nuget.org" --api-key ${{secrets.NugetKey}} # API key for the NuGet feed
+ run: |
+ for project in Json-Rpc AustinHarris.JsonRpc.Newtonsoft AustinHarris.JsonRpc.SystemTextJson AustinHarris.JsonRpc.AspNetCore; do
+ dotnet nuget push "$project/bin/Release/"*.nupkg --skip-duplicate --source "https://api.nuget.org/v3/index.json" --api-key ${{ secrets.NugetKey }} # API key for the NuGet feed
+ done
diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml
index f3e06e9..e22a198 100644
--- a/.github/workflows/build_pull_request.yml
+++ b/.github/workflows/build_pull_request.yml
@@ -1,4 +1,4 @@
-name: Pull Reqest
+name: Pull Request
on:
pull_request:
paths-ignore:
@@ -11,25 +11,34 @@ jobs:
strategy:
matrix:
os: ['windows-latest','ubuntu-latest']
- dotnet-version: ['3.1.201']
- project : ['Json-Rpc']
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v2
- - name: Setup .NET Core
- uses: actions/setup-dotnet@v1
+ - uses: actions/checkout@v7
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v6
with:
- dotnet-version: ${{matrix.dotnet-version}}
+ # global.json pins the SDK; the 8.0 runtime is needed for the net8.0 test target.
+ global-json-file: global.json
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
- name: Install dependencies
- run: dotnet restore
+ run: dotnet restore AustinHarris.JsonRpc.sln
+ # Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the
+ # solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build.
- name: Build
- run: dotnet build ${{matrix.project}} --configuration Release --version-suffix ci-${{ github.run_id }}-${{ github.run_number }}
+ run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore --version-suffix ci-${{ github.run_id }}-${{ github.run_number }}
- name: Test
- run: dotnet test AustinHarris.JsonRpcTestN
- # Publish
+ run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build
+ # Publish all four pre-release packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core).
+ # Non-fatal: the build and tests are the pull-request verdict; a rejected key (403) or a fork's missing secret
+ # shows as a warning here and fails loudly in the master workflow instead.
- name: publish nuget version change
- if: ${{matrix.os == 'ubuntu-latest'}}
- run: dotnet nuget push Json-Rpc/bin/Release/*.nupkg --skip-duplicate --source "https://www.nuget.org" --api-key ${{secrets.NugetKey}} # API key for the NuGet feed
-
\ No newline at end of file
+ if: ${{ matrix.os == 'ubuntu-latest' }}
+ continue-on-error: true
+ run: |
+ for project in Json-Rpc AustinHarris.JsonRpc.Newtonsoft AustinHarris.JsonRpc.SystemTextJson AustinHarris.JsonRpc.AspNetCore; do
+ dotnet nuget push "$project/bin/Release/"*.nupkg --skip-duplicate --source "https://api.nuget.org/v3/index.json" --api-key ${{ secrets.NugetKey }} # API key for the NuGet feed
+ done
diff --git a/.github/workflows/charts.yml b/.github/workflows/charts.yml
new file mode 100644
index 0000000..34db1b1
--- /dev/null
+++ b/.github/workflows/charts.yml
@@ -0,0 +1,24 @@
+name: Benchmark charts
+# The committed charts, the explorer page and the README figures must agree with benchmarks/charts/benchmarks.json.
+on:
+ pull_request:
+ paths:
+ - "benchmarks/charts/**"
+ - "README.md"
+ - "samples/WasmHost/README.md"
+ push:
+ branches: [ master ]
+ paths:
+ - "benchmarks/charts/**"
+ - "README.md"
+ - "samples/WasmHost/README.md"
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: Renderer tests
+ run: python3 -m unittest benchmarks/charts/test_render.py
+ - name: Committed outputs and README figures match the data
+ run: python3 benchmarks/charts/render.py --check
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..6562457
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,40 @@
+name: Benchmark explorer (GitHub Pages)
+on:
+ push:
+ branches: [ master ]
+ paths:
+ - "benchmarks/charts/**"
+ - ".github/workflows/pages.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v7
+ # The committed outputs must match the data: a stale chart or explorer fails the deploy.
+ - name: Check the committed charts against the data
+ run: python3 benchmarks/charts/render.py --check
+ - name: Stage the site
+ run: |
+ mkdir -p site
+ cp benchmarks/charts/explorer.html site/index.html
+ cp benchmarks/charts/*.svg benchmarks/charts/*.json site/
+ - uses: actions/configure-pages@v6
+ - uses: actions/upload-pages-artifact@v5
+ with:
+ path: site
+ - id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index 6ea46be..e8a3a67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,3 +108,9 @@ Backup*/
UpgradeLog*.XML
.nuget/NuGet.exe
.vs/
+
+# JetBrains Rider / IntelliJ
+.idea/
+
+# Python renderer caches
+__pycache__/
diff --git a/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj
new file mode 100644
index 0000000..ccbffb3
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj
@@ -0,0 +1,36 @@
+
+
+
+ Austin Harris
+ Austin Harris
+ Json-Rpc.Net ASP.NET Core host
+ ASP.NET Core / Kestrel hosting for JSON-RPC.Net: MapJsonRpc endpoint (PipeReader in, BodyWriter out, no strings), a raw Kestrel ConnectionHandler for JSON-RPC over TCP, and DI registration of services.
+ 2.0.0
+ $(VersionSuffix)
+ Austin Harris
+ https://github.com/Astn/JSON-RPC.NET
+ https://github.com/Astn/JSON-RPC.NET
+ git
+ MIT
+ README.md
+ json-rpc;jsonrpc;json;rpc;server;aspnetcore;kestrel;pipelines
+ net8.0;net10.0
+ latest
+ disable
+ true
+ AustinHarris.JsonRpc.AspNetCore
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs
new file mode 100644
index 0000000..21c923f
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Threading.Tasks;
+using AustinHarris.JsonRpc.Serialization;
+using Microsoft.AspNetCore.Connections;
+
+namespace AustinHarris.JsonRpc.AspNetCore
+{
+ public partial class JsonRpcConnectionHandler
+ {
+ private async Task RunAsynchronousMethodsAsync(ConnectionContext connection)
+ {
+ var input = connection.Transport.Input;
+ var output = connection.Transport.Output;
+ var token = connection.ConnectionClosed;
+ string session = _options.SessionId ?? Handler.DefaultSessionId();
+ // Keep result commits separate from transport flushes: a method can finish on another
+ // thread while an earlier reply is waiting for transport backpressure.
+ using var reply = new PooledByteBufferWriter();
+ try
+ {
+ while (true)
+ {
+ var result = await input.ReadAsync(token).ConfigureAwait(false);
+ var buffer = result.Buffer;
+ bool wrote = false;
+ try
+ {
+ while (JsonFramer.TryReadDocument(ref buffer, out var document))
+ {
+ token.ThrowIfCancellationRequested();
+ if (document.Length <= 1 && document.First.Span[0] != (byte)'{' && document.First.Span[0] != (byte)'[') continue;
+ if (document.Length > _options.MaxRequestBytes)
+ {
+ connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
+ return;
+ }
+ reply.Clear();
+ var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token);
+ if (!pending.IsCompleted && wrote)
+ {
+ bool closed = false;
+ try
+ {
+ var flush = await output.FlushAsync(token).ConfigureAwait(false);
+ closed = flush.IsCompleted || flush.IsCanceled;
+ wrote = false;
+ }
+ finally
+ {
+ // Even a failed flush cannot release the input or reply while invocation runs.
+ await pending.ConfigureAwait(false);
+ }
+ if (closed) return;
+ }
+ else await pending.ConfigureAwait(false);
+ token.ThrowIfCancellationRequested();
+ if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; }
+ }
+ if (wrote)
+ {
+ var flush = await output.FlushAsync(token).ConfigureAwait(false);
+ if (flush.IsCompleted || flush.IsCanceled) return;
+ }
+ if (result.IsCompleted || result.IsCanceled) return;
+ if (buffer.Length > _options.MaxRequestBytes)
+ {
+ connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
+ return;
+ }
+ }
+ finally { input.AdvanceTo(buffer.Start, buffer.End); }
+ }
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested) { }
+ catch (ConnectionAbortedException) { }
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs
new file mode 100644
index 0000000..d664604
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Buffers;
+using System.Threading.Tasks;
+using AustinHarris.JsonRpc.Serialization;
+using Microsoft.AspNetCore.Connections;
+using Microsoft.Extensions.Options;
+
+namespace AustinHarris.JsonRpc.AspNetCore
+{
+ ///
+ /// JSON-RPC over a raw Kestrel connection (TCP, Unix socket, named pipe): clients send JSON documents back to
+ /// back (optionally whitespace / newline separated) and receive responses in order. Wire it up with
+ /// kestrel.ListenLocalhost(port, l => l.UseConnectionHandler<JsonRpcConnectionHandler>()).
+ /// The connection's is the RPC context for every call.
+ ///
+ public partial class JsonRpcConnectionHandler : ConnectionHandler
+ {
+ private readonly JsonRpcOptions _options;
+
+ public JsonRpcConnectionHandler(IOptions options)
+ {
+ _options = options?.Value ?? new JsonRpcOptions();
+ }
+
+ /// Processes a connection using the hosting mode selected in options.
+ public override Task OnConnectedAsync(ConnectionContext connection)
+ {
+ return _options.EnableAsyncMethods ? RunAsynchronousMethodsAsync(connection) : RunSynchronousMethodsAsync(connection);
+ }
+
+ private async Task RunSynchronousMethodsAsync(ConnectionContext connection)
+ {
+ var input = connection.Transport.Input;
+ var output = connection.Transport.Output;
+ string session = _options.SessionId ?? Handler.DefaultSessionId();
+
+ while (true)
+ {
+ var result = await input.ReadAsync(connection.ConnectionClosed).ConfigureAwait(false);
+ var buffer = result.Buffer;
+ bool wrote = false;
+
+ while (JsonFramer.TryReadDocument(ref buffer, out var document))
+ {
+ // the framer hands back a one-byte slice for anything that is not '{' or '[': drop it
+ if (document.Length > 1 || document.First.Span[0] == (byte)'{' || document.First.Span[0] == (byte)'[')
+ {
+ if (document.Length > _options.MaxRequestBytes)
+ {
+ connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
+ return;
+ }
+ JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer);
+ wrote = true;
+ }
+ }
+
+ if (wrote)
+ {
+ var flush = await output.FlushAsync(connection.ConnectionClosed).ConfigureAwait(false);
+ if (flush.IsCompleted || flush.IsCanceled) break;
+ }
+
+ if (result.IsCompleted || result.IsCanceled)
+ {
+ input.AdvanceTo(buffer.Start, buffer.End);
+ break;
+ }
+ if (buffer.Length > _options.MaxRequestBytes)
+ {
+ connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
+ return;
+ }
+ // consumed up to the last complete document, examined everything
+ input.AdvanceTo(buffer.Start, buffer.End);
+ }
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs
new file mode 100644
index 0000000..6e0b07b
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Buffers;
+using System.IO.Pipelines;
+using System.Threading;
+using System.Threading.Tasks;
+using AustinHarris.JsonRpc.Serialization;
+using Microsoft.AspNetCore.Http;
+
+namespace AustinHarris.JsonRpc.AspNetCore
+{
+ ///
+ /// The HTTP request handler: reads the body through , runs the processor on the
+ /// it yields, and writes the response straight into
+ /// . No strings, no intermediate byte arrays.
+ ///
+ public static class JsonRpcEndpoint
+ {
+ /// Processes an HTTP request using the synchronous or asynchronous mode selected in options.
+ public static Task HandleAsync(HttpContext http, JsonRpcOptions options)
+ {
+ return options?.EnableAsyncMethods == true ? HandleAsynchronousMethodsAsync(http, options) : HandleSynchronousMethodsAsync(http, options);
+ }
+
+ internal static async Task HandleSynchronousMethodsAsync(HttpContext http, JsonRpcOptions options)
+ {
+ options ??= new JsonRpcOptions();
+ if (!HttpMethods.IsPost(http.Request.Method))
+ {
+ http.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
+ http.Response.Headers.Allow = "POST";
+ return;
+ }
+
+ var reader = http.Request.BodyReader;
+ var ct = http.RequestAborted;
+ ReadResult result;
+ while (true)
+ {
+ result = await reader.ReadAsync(ct).ConfigureAwait(false);
+ if (result.IsCompleted || result.IsCanceled) break;
+ if (result.Buffer.Length > options.MaxRequestBytes)
+ {
+ reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
+ http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+ // nothing consumed, everything examined: ReadAsync waits for more bytes
+ reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
+ }
+
+ var buffer = result.Buffer;
+ try
+ {
+ if (buffer.Length > options.MaxRequestBytes)
+ {
+ http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+
+ string session = options.SessionSelector?.Invoke(http) ?? options.SessionId ?? Handler.DefaultSessionId();
+ object context = options.ContextFactory != null ? options.ContextFactory(http) : http;
+
+ http.Response.ContentType = options.ResponseContentType;
+ var counting = new CountingBufferWriter(http.Response.BodyWriter);
+ JsonRpcProcessor.Process(session, in buffer, counting, context, options.Serializer);
+
+ if (counting.Written == 0)
+ {
+ if (options.NoContentForNotifications)
+ {
+ http.Response.ContentType = null;
+ http.Response.StatusCode = StatusCodes.Status204NoContent;
+ }
+ else
+ {
+ http.Response.ContentLength = 0;
+ }
+ return;
+ }
+ await http.Response.BodyWriter.FlushAsync(ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ reader.AdvanceTo(buffer.End);
+ }
+ }
+
+ internal static async Task HandleAsynchronousMethodsAsync(HttpContext http, JsonRpcOptions options)
+ {
+ options ??= new JsonRpcOptions();
+ if (!HttpMethods.IsPost(http.Request.Method))
+ {
+ http.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
+ http.Response.Headers.Allow = "POST";
+ return;
+ }
+
+ var reader = http.Request.BodyReader;
+ var ct = http.RequestAborted;
+ ReadResult result;
+ while (true)
+ {
+ result = await reader.ReadAsync(ct).ConfigureAwait(false);
+ if (result.IsCompleted || result.IsCanceled) break;
+ if (result.Buffer.Length > options.MaxRequestBytes)
+ {
+ reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
+ http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+ // nothing consumed, everything examined: ReadAsync waits for more bytes
+ reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
+ }
+
+ var buffer = result.Buffer;
+ try
+ {
+ if (buffer.Length > options.MaxRequestBytes)
+ {
+ http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ return;
+ }
+
+ string session = options.SessionSelector?.Invoke(http) ?? options.SessionId ?? Handler.DefaultSessionId();
+ object context = options.ContextFactory != null ? options.ContextFactory(http) : http;
+
+ http.Response.ContentType = options.ResponseContentType;
+ var counting = new CountingBufferWriter(http.Response.BodyWriter);
+ await JsonRpcProcessor.ProcessAsync(session, buffer, counting, context, options.Serializer, ct).ConfigureAwait(false);
+
+ if (counting.Written == 0)
+ {
+ if (options.NoContentForNotifications)
+ {
+ http.Response.ContentType = null;
+ http.Response.StatusCode = StatusCodes.Status204NoContent;
+ }
+ else
+ {
+ http.Response.ContentLength = 0;
+ }
+ return;
+ }
+ await http.Response.BodyWriter.FlushAsync(ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ reader.AdvanceTo(buffer.End);
+ }
+ }
+
+ /// Tracks how many bytes a processor call advanced, so an empty (notification) response can be detected before headers are sent.
+ internal sealed class CountingBufferWriter : IBufferWriter
+ {
+ private readonly IBufferWriter _inner;
+ public long Written;
+
+ public CountingBufferWriter(IBufferWriter inner) { _inner = inner; }
+
+ public void Advance(int count) { Written += count; _inner.Advance(count); }
+ public Memory GetMemory(int sizeHint = 0) => _inner.GetMemory(sizeHint);
+ public Span GetSpan(int sizeHint = 0) => _inner.GetSpan(sizeHint);
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs
new file mode 100644
index 0000000..eb9f86d
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs
@@ -0,0 +1,40 @@
+using System;
+using AustinHarris.JsonRpc.Serialization;
+using Microsoft.AspNetCore.Http;
+
+namespace AustinHarris.JsonRpc.AspNetCore
+{
+ /// Settings shared by the HTTP endpoint and the raw connection handler.
+ public class JsonRpcOptions
+ {
+ /// Awaits Task and ValueTask methods through ProcessAsync. False preserves synchronous processing.
+ public bool EnableAsyncMethods { get; set; }
+
+ /// The session whose registered methods answer requests. Null means the default session.
+ public string SessionId { get; set; }
+
+ ///
+ /// Picks the session per HTTP request (for example from a route value or a header). When set it takes
+ /// precedence over . Not used by the raw connection handler.
+ ///
+ public Func SessionSelector { get; set; }
+
+ /// The serializer to use. Null means the session's serializer, falling back to .
+ public JsonRpcSerializer Serializer { get; set; }
+
+ ///
+ /// Produces the object handed to methods through and to pre/post handlers.
+ /// Defaults to the itself for HTTP and the connection context for raw connections.
+ ///
+ public Func ContextFactory { get; set; }
+
+ /// Largest request body accepted, in bytes. Larger bodies get 413. Default 4 MB.
+ public long MaxRequestBytes { get; set; } = 4 * 1024 * 1024;
+
+ /// Content type of responses. Default "application/json".
+ public string ResponseContentType { get; set; } = "application/json";
+
+ /// Whether a notification (no response) answers 204 No Content (default) or 200 with an empty body.
+ public bool NoContentForNotifications { get; set; } = true;
+ }
+}
diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs
new file mode 100644
index 0000000..8b6d528
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+
+namespace AustinHarris.JsonRpc.AspNetCore
+{
+ public static class JsonRpcServiceCollectionExtensions
+ {
+ /// Registers JSON-RPC options and the binder that registers DI-constructed services at startup.
+ public static IServiceCollection AddJsonRpc(this IServiceCollection services, Action configure = null)
+ {
+ if (configure != null) services.Configure(configure);
+ else services.AddOptions();
+ services.TryAddSingleton();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ return services;
+ }
+
+ ///
+ /// Registers as a singleton built by the container and binds every
+ /// [JsonRpcMethod] on it to the session when the host starts. Any class works, controllers included:
+ /// dependencies come from DI, the class does not need to derive from .
+ ///
+ public static IServiceCollection AddJsonRpcService(this IServiceCollection services, string sessionId = null) where TService : class
+ {
+ services.TryAddSingleton();
+ services.AddSingleton(new JsonRpcServiceRegistration(typeof(TService), sessionId));
+ return services;
+ }
+
+ /// Registers every class in that declares at least one [JsonRpcMethod].
+ public static IServiceCollection AddJsonRpcServicesFromAssembly(this IServiceCollection services, Assembly assembly, string sessionId = null)
+ {
+ foreach (var type in assembly.GetTypes())
+ {
+ if (type.IsAbstract || type.IsInterface || type.IsGenericTypeDefinition) continue;
+ bool hasRpc = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
+ .Any(m => m.IsDefined(typeof(JsonRpcMethodAttribute), false));
+ if (!hasRpc) continue;
+ services.TryAddSingleton(type);
+ services.AddSingleton(new JsonRpcServiceRegistration(type, sessionId));
+ }
+ return services;
+ }
+
+ ///
+ /// Maps a POST endpoint that processes JSON-RPC documents. Options default to the registered
+ /// (see ); pass to override per endpoint.
+ ///
+ public static IEndpointConventionBuilder MapJsonRpc(this IEndpointRouteBuilder endpoints, string pattern = "/jsonrpc", JsonRpcOptions options = null)
+ {
+ var resolved = options ?? endpoints.ServiceProvider.GetService>()?.Value ?? new JsonRpcOptions();
+ return resolved.EnableAsyncMethods
+ ? endpoints.MapPost(pattern, http => JsonRpcEndpoint.HandleAsynchronousMethodsAsync(http, resolved))
+ : endpoints.MapPost(pattern, http => JsonRpcEndpoint.HandleSynchronousMethodsAsync(http, resolved));
+ }
+
+ internal sealed class JsonRpcServiceRegistration
+ {
+ public JsonRpcServiceRegistration(Type type, string sessionId) { Type = type; SessionId = sessionId; }
+ public Type Type { get; }
+ public string SessionId { get; }
+ }
+
+ /// Resolves registered services from the container and binds them before the host starts accepting requests.
+ internal sealed class JsonRpcBinderHostedService : IHostedService
+ {
+ private readonly IServiceProvider _provider;
+ private readonly IEnumerable _registrations;
+ private readonly JsonRpcOptions _options;
+
+ public JsonRpcBinderHostedService(IServiceProvider provider, IEnumerable registrations, IOptions options)
+ {
+ _provider = provider;
+ _registrations = registrations;
+ _options = options.Value;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ string defaultSession = Handler.DefaultSessionId();
+ foreach (var r in _registrations)
+ {
+ var instance = _provider.GetRequiredService(r.Type);
+ // The effective session: the registration's own, then JsonRpcOptions.SessionId, then the default.
+ var session = r.SessionId ?? _options.SessionId ?? defaultSession;
+ // A JsonRpcService subclass already bound itself in its constructor, to the default session
+ // (parameterless base constructor). Bind it here whenever the effective session is a different
+ // one, otherwise the configured session would answer -32601 for it; rebinding the same
+ // instance to the same session is harmless (the method table entry is replaced).
+ if (instance is JsonRpcService && session == defaultSession) continue;
+ ServiceBinder.BindService(session, instance);
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.AspNetCore/README.md b/AustinHarris.JsonRpc.AspNetCore/README.md
new file mode 100644
index 0000000..2ccb3b3
--- /dev/null
+++ b/AustinHarris.JsonRpc.AspNetCore/README.md
@@ -0,0 +1,82 @@
+# AustinHarris.JsonRpc.AspNetCore
+
+Hosts [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) in ASP.NET Core. The core processes requests as
+UTF-8 bytes, so the HTTP endpoint reads the body with `PipeReader` and writes straight into
+`Response.BodyWriter`; nothing is turned into a string on the way through. A `ConnectionHandler` does the
+same for JSON-RPC over a raw Kestrel connection (TCP, Unix socket, named pipe).
+
+## HTTP endpoint
+
+```csharp
+using AustinHarris.JsonRpc;
+using AustinHarris.JsonRpc.AspNetCore;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddJsonRpc(o =>
+{
+ // o.EnableAsyncMethods = true; // await Task and ValueTask service methods
+ // o.Serializer = new SystemTextJsonRpcSerializer(); // optional, default is the built-in serializer
+ // o.SessionSelector = http => http.Request.RouteValues["session"] as string;
+});
+builder.Services.AddJsonRpcService(); // any class with [JsonRpcMethod] methods, built by DI
+
+var app = builder.Build();
+app.MapJsonRpc("/rpc");
+app.Run();
+
+public class CalculatorService
+{
+ private readonly ILogger _log;
+ public CalculatorService(ILogger log) => _log = log;
+
+ [JsonRpcMethod]
+ public double add(double l, double r) => l + r;
+}
+```
+
+`POST /rpc` with a request or a batch answers `200 application/json`; a notification answers `204`.
+Inside a method `JsonRpcContext.Current().Value` is the `HttpContext` (override with `ContextFactory`).
+Classes deriving from `JsonRpcService` still bind themselves; `AddJsonRpcService()` is for classes that
+take constructor dependencies, and `AddJsonRpcServicesFromAssembly(typeof(Program).Assembly)` registers every
+class that declares a `[JsonRpcMethod]`, MVC controllers included.
+
+Because it is an ordinary endpoint, `RequireAuthorization()`, rate limiting, output caching and the rest of
+the middleware pipeline compose with it:
+
+```csharp
+app.MapJsonRpc("/rpc").RequireAuthorization("api");
+```
+
+## Raw connection (TCP)
+
+```csharp
+builder.WebHost.ConfigureKestrel(k =>
+{
+ k.ListenAnyIP(9000, l => l.UseConnectionHandler());
+});
+```
+
+Clients write JSON documents back to back (a newline between them is fine); each document is answered in
+order on the same connection, notifications produce nothing. The `ConnectionContext` is the RPC context.
+
+With `EnableAsyncMethods = true`, HTTP awaits `ProcessAsync` with `HttpContext.RequestAborted`;
+the body reader remains leased until invocation finishes. Raw connections await each framed document
+before starting the next and flush earlier completed replies before waiting for a slow document.
+Notifications are awaited and keep the same HTTP status rules. Connection cancellation is cooperative:
+the processor waits for the running method to terminate before releasing input and discards its staged response.
+Mark a `CancellationToken` parameter with `[JsonRpcCancellation]` to receive that token.
+The default mode preserves synchronous processing and rejects async methods at call time.
+
+## Options
+
+| Option | Default | Meaning |
+|---|---|---|
+| `EnableAsyncMethods` | false | use ProcessAsync for Task/ValueTask methods, with host cancellation |
+| `SessionId` | default session | which session's methods answer |
+| `SessionSelector` | null | pick the session per HTTP request |
+| `Serializer` | session, then `Config.Serializer` | serializer for this host |
+| `ContextFactory` | `HttpContext` | what `JsonRpcContext.Current()` returns |
+| `MaxRequestBytes` | 4 MB | larger bodies get 413 (HTTP) or abort the connection |
+| `ResponseContentType` | `application/json` | |
+| `NoContentForNotifications` | true | 204 for notifications, otherwise 200 with an empty body |
diff --git a/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj b/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj
new file mode 100644
index 0000000..89ce515
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj
@@ -0,0 +1,40 @@
+
+
+
+ Austin Harris
+ Austin Harris
+ Json-Rpc.Net Json.NET serializer
+ Json.NET (Newtonsoft.Json) serializer for JSON-RPC.Net. Lenient parsing and full Json.NET conversion semantics; pass JsonSerializerSettings to control it.
+ 2.0.0
+ $(VersionSuffix)
+ Austin Harris
+ https://github.com/Astn/JSON-RPC.NET
+ https://github.com/Astn/JSON-RPC.NET
+ git
+ MIT
+ README.md
+ json-rpc;jsonrpc;json;rpc;server;json.net;newtonsoft
+ netstandard2.0;netstandard2.1;net8.0;net10.0
+ latest
+ true
+ true
+ AustinHarris.JsonRpc.Newtonsoft
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AustinHarris.JsonRpc.Newtonsoft/BufferWriterTextWriter.cs b/AustinHarris.JsonRpc.Newtonsoft/BufferWriterTextWriter.cs
new file mode 100644
index 0000000..2f55fec
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/BufferWriterTextWriter.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Buffers;
+using System.Globalization;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace AustinHarris.JsonRpc.Newtonsoft
+{
+ ///
+ /// A reusable that UTF-8 encodes straight into an .
+ /// Characters are encoded in place (GetSpan / Advance) with a stateful , so surrogate
+ /// pairs split across writes stay intact and nothing is buffered on this side; drains
+ /// the encoder. No BOM is ever written.
+ ///
+ internal sealed class BufferWriterTextWriter : TextWriter
+ {
+ private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false);
+
+ private readonly Encoder _encoder = Utf8NoBom.GetEncoder();
+ private IBufferWriter _output;
+#if NETSTANDARD2_0
+ private readonly char[] _one = new char[1];
+ private char[] _charScratch;
+ private byte[] _byteScratch;
+#endif
+
+ public BufferWriterTextWriter() : base(CultureInfo.InvariantCulture) { }
+
+ public override Encoding Encoding => Utf8NoBom;
+
+ /// Points the writer at a new destination and clears any encoder state.
+ public void Reset(IBufferWriter output)
+ {
+ _output = output ?? throw new ArgumentNullException(nameof(output));
+ _encoder.Reset();
+ }
+
+ /// Drops the reference to the destination once the value has been flushed.
+ public void Detach() => _output = null;
+
+ public override void Flush()
+ {
+ if (_output == null) return;
+#if NETSTANDARD2_0
+ Encode(Array.Empty(), 0, 0, flush: true);
+#else
+ Encode(ReadOnlySpan.Empty, flush: true);
+#endif
+ }
+
+ // ------------------------------------------------------------------ overrides Json.NET uses
+
+ public override void Write(char value)
+ {
+#if NETSTANDARD2_0
+ _one[0] = value;
+ Encode(_one, 0, 1, flush: false);
+#else
+ Span one = stackalloc char[1];
+ one[0] = value;
+ Encode(one, flush: false);
+#endif
+ }
+
+ public override void Write(char[] buffer) => Write(buffer, 0, buffer.Length);
+
+ public override void Write(char[] buffer, int index, int count)
+ {
+ if (count == 0) return;
+#if NETSTANDARD2_0
+ Encode(buffer, index, count, flush: false);
+#else
+ Encode(new ReadOnlySpan(buffer, index, count), flush: false);
+#endif
+ }
+
+ public override void Write(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return;
+#if NETSTANDARD2_0
+ if (_charScratch == null || _charScratch.Length < value.Length)
+ {
+ if (_charScratch != null) ArrayPool.Shared.Return(_charScratch);
+ _charScratch = ArrayPool.Shared.Rent(value.Length);
+ }
+ value.CopyTo(0, _charScratch, 0, value.Length);
+ Encode(_charScratch, 0, value.Length, flush: false);
+#else
+ Encode(value.AsSpan(), flush: false);
+#endif
+ }
+
+#if !NETSTANDARD2_0
+ public override void Write(ReadOnlySpan buffer)
+ {
+ if (!buffer.IsEmpty) Encode(buffer, flush: false);
+ }
+#endif
+
+ // ------------------------------------------------------------------ encoding
+
+#if NETSTANDARD2_0
+ private void Encode(char[] chars, int index, int count, bool flush)
+ {
+ var output = _output ?? throw new InvalidOperationException("The writer is not attached to an output.");
+ do
+ {
+ var memory = output.GetMemory(SizeHint(count));
+ if (MemoryMarshal.TryGetArray(memory, out var segment))
+ {
+ _encoder.Convert(chars, index, count, segment.Array, segment.Offset, segment.Count, flush, out int charsUsed, out int bytesUsed, out _);
+ output.Advance(bytesUsed);
+ index += charsUsed;
+ count -= charsUsed;
+ }
+ else
+ {
+ // not array-backed: encode through a pooled scratch buffer and copy
+ if (_byteScratch == null) _byteScratch = ArrayPool.Shared.Rent(4096);
+ _encoder.Convert(chars, index, count, _byteScratch, 0, _byteScratch.Length, flush, out int charsUsed, out int bytesUsed, out _);
+ var dest = output.GetSpan(bytesUsed);
+ new ReadOnlySpan(_byteScratch, 0, bytesUsed).CopyTo(dest);
+ output.Advance(bytesUsed);
+ index += charsUsed;
+ count -= charsUsed;
+ }
+ } while (count > 0);
+ }
+#else
+ private void Encode(ReadOnlySpan chars, bool flush)
+ {
+ var output = _output ?? throw new InvalidOperationException("The writer is not attached to an output.");
+ do
+ {
+ var dest = output.GetSpan(SizeHint(chars.Length));
+ _encoder.Convert(chars, dest, flush, out int charsUsed, out int bytesUsed, out _);
+ output.Advance(bytesUsed);
+ chars = chars.Slice(charsUsed);
+ } while (!chars.IsEmpty);
+ }
+#endif
+
+ /// Asks for the whole encoded size for short runs and a bounded chunk for long ones (the loop above handles the remainder).
+ private static int SizeHint(int charCount)
+ {
+ const int Chunk = 4096;
+ if (charCount >= Chunk) return Chunk;
+ return Math.Max(16, (charCount + 1) * 3);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ _output = null;
+#if NETSTANDARD2_0
+ if (_charScratch != null) { ArrayPool.Shared.Return(_charScratch); _charScratch = null; }
+ if (_byteScratch != null) { ArrayPool.Shared.Return(_byteScratch); _byteScratch = null; }
+#endif
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.Newtonsoft/JsonArrayPool.cs b/AustinHarris.JsonRpc.Newtonsoft/JsonArrayPool.cs
new file mode 100644
index 0000000..bc25e5b
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/JsonArrayPool.cs
@@ -0,0 +1,20 @@
+using System.Buffers;
+using Newtonsoft.Json;
+
+namespace AustinHarris.JsonRpc.Newtonsoft
+{
+ /// Lets / rent their char buffers from .
+ internal sealed class JsonArrayPool : IArrayPool
+ {
+ public static readonly JsonArrayPool Instance = new JsonArrayPool();
+
+ private JsonArrayPool() { }
+
+ public char[] Rent(int minimumLength) => ArrayPool.Shared.Rent(minimumLength);
+
+ public void Return(char[] array)
+ {
+ if (array != null) ArrayPool.Shared.Return(array);
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpc.cs b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpc.cs
new file mode 100644
index 0000000..246a8cf
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpc.cs
@@ -0,0 +1,59 @@
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace AustinHarris.JsonRpc.Newtonsoft
+{
+ ///
+ /// Settings-based entry points for callers that used to pass a to
+ /// in 1.x. Each distinct settings instance is turned into one
+ /// the first time it is seen and reused afterwards (the settings are
+ /// snapshotted at that point, as does); null means
+ /// Json.NET defaults.
+ ///
+ public static class NewtonsoftJsonRpc
+ {
+ private static readonly NewtonsoftJsonRpcSerializer Default = new NewtonsoftJsonRpcSerializer();
+ private static readonly ConditionalWeakTable Cache =
+ new ConditionalWeakTable();
+
+ /// The serializer for (cached per settings instance; null = defaults).
+ public static NewtonsoftJsonRpcSerializer SerializerFor(JsonSerializerSettings settings)
+ {
+ if (settings == null) return Default;
+ return Cache.GetValue(settings, s => new NewtonsoftJsonRpcSerializer(s));
+ }
+
+ /// Processes a request on the default session with Json.NET configured by .
+ public static string ProcessSync(string jsonRpc, object context, JsonSerializerSettings settings)
+ {
+ return JsonRpcProcessor.ProcessSync(Handler.DefaultSessionId(), jsonRpc, context, SerializerFor(settings));
+ }
+
+ /// Processes a request on with Json.NET configured by .
+ public static string ProcessSync(string sessionId, string jsonRpc, object context, JsonSerializerSettings settings)
+ {
+ return JsonRpcProcessor.ProcessSync(sessionId, jsonRpc, context, SerializerFor(settings));
+ }
+
+ public static Task Process(string jsonRpc, object context, JsonSerializerSettings settings)
+ {
+ return JsonRpcProcessor.Process(Handler.DefaultSessionId(), jsonRpc, context, SerializerFor(settings));
+ }
+
+ public static Task Process(string sessionId, string jsonRpc, object context, JsonSerializerSettings settings)
+ {
+ return JsonRpcProcessor.Process(sessionId, jsonRpc, context, SerializerFor(settings));
+ }
+
+ public static void Process(JsonRpcStateAsync async, object context, JsonSerializerSettings settings)
+ {
+ JsonRpcProcessor.Process(Handler.DefaultSessionId(), async, context, SerializerFor(settings));
+ }
+
+ public static void Process(string sessionId, JsonRpcStateAsync async, object context, JsonSerializerSettings settings)
+ {
+ JsonRpcProcessor.Process(sessionId, async, context, SerializerFor(settings));
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs
new file mode 100644
index 0000000..645ad54
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Buffers;
+using AustinHarris.JsonRpc.Serialization;
+using Newtonsoft.Json;
+
+namespace AustinHarris.JsonRpc.Newtonsoft
+{
+ ///
+ /// Json.NET-backed serializer. The core parses the JSON-RPC envelope itself and hands this class the raw
+ /// UTF-8 bytes of individual values (params, results, error data); every conversion goes through one
+ /// built from , so converters, contract resolvers,
+ /// date/float handling and formatting behave exactly as they do with .
+ ///
+ /// Reading decodes the value bytes into a pooled char buffer and feeds it to a
+ /// whose scratch buffer is rented from ; nothing is materialised as a string.
+ /// Writing runs a per-thread over a that
+ /// UTF-8 encodes straight into the caller's . Output is compact, exactly one
+ /// JSON value, no trailing whitespace.
+ ///
+ ///
+ public sealed class NewtonsoftJsonRpcSerializer : JsonRpcSerializer
+ {
+ private readonly JsonSerializer _serializer;
+
+ public NewtonsoftJsonRpcSerializer() : this(null) { }
+
+ ///
+ /// Settings applied to every conversion (null = Json.NET defaults). The serializer is built once from
+ /// the settings; like , is honoured
+ /// as the baseline.
+ ///
+ public NewtonsoftJsonRpcSerializer(JsonSerializerSettings settings)
+ {
+ Settings = settings;
+ _serializer = JsonSerializer.CreateDefault(settings);
+ }
+
+ /// The settings used for every conversion (null = Json.NET defaults).
+ public JsonSerializerSettings Settings { get; }
+
+ /// The built from . Shared by every conversion; do not mutate it while requests are in flight.
+ public JsonSerializer Serializer => _serializer;
+
+ public override string Name => "newtonsoft";
+
+ /// Json.NET accepts single quotes, unquoted names and comments; let the envelope reader do the same.
+ public override bool Lenient => true;
+
+ /// The envelope reader enforces the same limit as (64 when unset).
+ public override int MaxDepth => _serializer.MaxDepth ?? 64;
+
+ // ------------------------------------------------------------------ read
+
+ public override T Read(ReadOnlySpan utf8Json)
+ {
+ var scratch = Scratch.Current;
+ var text = scratch.RentReader();
+ try
+ {
+ text.Reset(utf8Json);
+ using (var json = CreateReader(text))
+ {
+ return _serializer.Deserialize(json);
+ }
+ }
+ finally
+ {
+ scratch.ReturnReader(text);
+ }
+ }
+
+ public override object Read(ReadOnlySpan utf8Json, Type type)
+ {
+ var scratch = Scratch.Current;
+ var text = scratch.RentReader();
+ try
+ {
+ text.Reset(utf8Json);
+ using (var json = CreateReader(text))
+ {
+ // typeof(object) yields JObject / JArray / unwrapped primitives, exactly like JsonConvert.DeserializeObject(text)
+ return _serializer.Deserialize(json, type);
+ }
+ }
+ finally
+ {
+ scratch.ReturnReader(text);
+ }
+ }
+
+ private static JsonTextReader CreateReader(Utf8CharReader text)
+ {
+ return new JsonTextReader(text)
+ {
+ ArrayPool = JsonArrayPool.Instance,
+ CloseInput = false
+ };
+ }
+
+ // ------------------------------------------------------------------ write
+
+ public override void Write(IBufferWriter output, T value) => WriteCore(output, value, typeof(T));
+
+ public override void Write(IBufferWriter output, object value, Type type) => WriteCore(output, value, type ?? value?.GetType() ?? typeof(object));
+
+ private void WriteCore(IBufferWriter output, object value, Type type)
+ {
+ var scratch = Scratch.Current;
+ if (scratch.WriterInUse)
+ {
+ // re-entrant call on this thread (a converter serializing through the RPC serializer): use throwaway instances
+ var fresh = new BufferWriterTextWriter();
+ fresh.Reset(output);
+ Serialize(CreateWriter(fresh), fresh, value, type);
+ return;
+ }
+
+ scratch.WriterInUse = true;
+ var text = scratch.Text;
+ var json = scratch.Writer ?? (scratch.Writer = CreateWriter(text));
+ text.Reset(output);
+ bool completed = false;
+ try
+ {
+ Serialize(json, text, value, type);
+ // a JsonTextWriter can write any number of root values; it is only reusable while it is back at the root
+ completed = json.WriteState == WriteState.Start;
+ }
+ finally
+ {
+ if (!completed) scratch.Writer = null;
+ text.Detach();
+ scratch.WriterInUse = false;
+ }
+ }
+
+ private void Serialize(JsonTextWriter json, BufferWriterTextWriter text, object value, Type type)
+ {
+ _serializer.Serialize(json, value, type);
+ json.Flush(); // JsonTextWriter buffers nothing itself; this flushes the TextWriter (and its UTF-8 encoder)
+ text.Flush();
+ }
+
+ private static JsonTextWriter CreateWriter(BufferWriterTextWriter text)
+ {
+ return new JsonTextWriter(text)
+ {
+ ArrayPool = JsonArrayPool.Instance,
+ CloseOutput = false,
+ AutoCompleteOnClose = false,
+ Formatting = Formatting.None
+ };
+ }
+
+ // ------------------------------------------------------------------ per-thread scratch
+
+ ///
+ /// One decoder/encoder pair per thread. The is kept between calls (Json.NET
+ /// lets a writer emit successive root values); a cannot be rewound, so one is
+ /// created per read and only its char buffer is pooled.
+ ///
+ private sealed class Scratch
+ {
+ [ThreadStatic] private static Scratch _current;
+
+ public static Scratch Current => _current ?? (_current = new Scratch());
+
+ private readonly Utf8CharReader _reader = new Utf8CharReader();
+ private bool _readerInUse;
+
+ public readonly BufferWriterTextWriter Text = new BufferWriterTextWriter();
+ public JsonTextWriter Writer;
+ public bool WriterInUse;
+
+ public Utf8CharReader RentReader()
+ {
+ if (_readerInUse) return new Utf8CharReader();
+ _readerInUse = true;
+ return _reader;
+ }
+
+ public void ReturnReader(Utf8CharReader reader)
+ {
+ if (ReferenceEquals(reader, _reader)) _readerInUse = false;
+ else reader.Dispose();
+ }
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.Newtonsoft/README.md b/AustinHarris.JsonRpc.Newtonsoft/README.md
new file mode 100644
index 0000000..595640a
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/README.md
@@ -0,0 +1,78 @@
+# AustinHarris.JsonRpc.Newtonsoft
+
+Json.NET (Newtonsoft.Json) serializer for [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) 2.0.
+
+The core package (`AustinHarris.JsonRpc`) parses the JSON-RPC envelope itself and ships a small dependency-free
+serializer for parameters and results. Install this package when you want Json.NET to do the value conversions:
+its converters, contract resolvers, `[JsonProperty]` attributes, date/float handling, and its tolerance for
+non-strict JSON.
+
+```
+dotnet add package AustinHarris.JsonRpc.Newtonsoft
+```
+
+## Choosing the serializer
+
+Process-wide (every session that does not override it):
+
+```csharp
+using AustinHarris.JsonRpc;
+using AustinHarris.JsonRpc.Newtonsoft;
+using Newtonsoft.Json;
+
+var settings = new JsonSerializerSettings { DateFormatString = "yyyy-MM-dd" }; // optional
+Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings));
+```
+
+Per session:
+
+```csharp
+Handler.GetSessionHandler("session-42").Serializer = new NewtonsoftJsonRpcSerializer(settings);
+// or: Config.SetSerializer("session-42", new NewtonsoftJsonRpcSerializer(settings));
+```
+
+Per call (overrides both):
+
+```csharp
+var serializer = new NewtonsoftJsonRpcSerializer(settings);
+string response = JsonRpcProcessor.ProcessSync(sessionId, json, context, serializer);
+```
+
+Create the serializer once and reuse it: it holds one `JsonSerializer` built from the settings, and the processor
+caches an envelope reader per serializer instance.
+
+## Settings-based helpers (1.x compatibility)
+
+The `JsonSerializerSettings` overloads that `JsonRpcProcessor` had in 1.x live here now:
+
+```csharp
+string response = NewtonsoftJsonRpc.ProcessSync(sessionId, json, context, settings);
+Task task = NewtonsoftJsonRpc.Process(sessionId, json, context, settings);
+NewtonsoftJsonRpc.Process(sessionId, stateAsync, context, settings);
+```
+
+Each distinct settings instance is turned into a serializer the first time it is seen and reused afterwards
+(`NewtonsoftJsonRpc.SerializerFor(settings)` gives you that instance). `null` settings means Json.NET defaults.
+
+## What you get
+
+* Every conversion honours the settings: params, results, `error.data`, and the `JsonRequest.Params` handed to
+ pre/post-process handlers (a `JObject` / `JArray` / primitive, as `JsonConvert.DeserializeObject` returns).
+* Json.NET's defaults already match the library's wire conventions: compact output, `3.0` for whole floating
+ values, ISO-8601 dates (fraction only when non-zero, trailing zeros trimmed) with the offset, `char` as a one-character string, nulls
+ written, members in declaration order, case-insensitive member names on input, numbers coerced to
+ `bool`/`char`/floating types.
+* Leniency. Json.NET accepts more than RFC 8259, and with this serializer selected so does the envelope reader:
+ single-quoted strings, unquoted member names and trailing commas are accepted in the request, e.g.
+ `{method:'add',params:[1,2],id:1}`. With the built-in serializer the same request is a `-32700` parse error.
+* `JsonConvert.DefaultSettings`, if your process sets it, is the baseline exactly as it is for `JsonConvert`.
+
+Conversion failures throw and are reported to the client as `-32603 Internal Error`.
+
+## Performance notes
+
+Reading decodes the value's UTF-8 bytes once into a pooled `char[]` and hands that to a `JsonTextReader` whose
+own buffer is rented from `ArrayPool`; no `string` or `MemoryStream` is created. Writing keeps one
+`JsonTextWriter` per thread over a `TextWriter` that UTF-8 encodes straight into the caller's
+`IBufferWriter` (a `PipeWriter`, the HTTP body writer, or the processor's pooled buffer), so the JSON is
+never assembled as a string first.
diff --git a/AustinHarris.JsonRpc.Newtonsoft/Utf8CharReader.cs b/AustinHarris.JsonRpc.Newtonsoft/Utf8CharReader.cs
new file mode 100644
index 0000000..bbeedd9
--- /dev/null
+++ b/AustinHarris.JsonRpc.Newtonsoft/Utf8CharReader.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Text;
+
+namespace AustinHarris.JsonRpc.Newtonsoft
+{
+ ///
+ /// A reusable over the UTF-8 bytes of one JSON value. The bytes are decoded once
+ /// into a pooled char buffer (no string, no MemoryStream) and served to .
+ ///
+ internal sealed class Utf8CharReader : TextReader
+ {
+ private char[] _chars = ArrayPool.Shared.Rent(512);
+ private int _length;
+ private int _pos;
+
+ /// Decodes into the buffer and rewinds.
+ public void Reset(ReadOnlySpan utf8)
+ {
+ int max = Encoding.UTF8.GetMaxCharCount(utf8.Length);
+ if (_chars.Length < max)
+ {
+ ArrayPool.Shared.Return(_chars);
+ _chars = ArrayPool.Shared.Rent(max);
+ }
+#if NETSTANDARD2_0
+ if (utf8.Length == 0)
+ {
+ _length = 0;
+ }
+ else
+ {
+ unsafe
+ {
+ fixed (byte* src = utf8)
+ fixed (char* dst = _chars)
+ {
+ _length = Encoding.UTF8.GetChars(src, utf8.Length, dst, _chars.Length);
+ }
+ }
+ }
+#else
+ _length = Encoding.UTF8.GetChars(utf8, _chars);
+#endif
+ _pos = 0;
+ }
+
+ public override int Peek() => _pos < _length ? _chars[_pos] : -1;
+
+ public override int Read() => _pos < _length ? _chars[_pos++] : -1;
+
+ public override int Read(char[] buffer, int index, int count)
+ {
+ int n = Math.Min(count, _length - _pos);
+ if (n <= 0) return 0;
+ Array.Copy(_chars, _pos, buffer, index, n);
+ _pos += n;
+ return n;
+ }
+
+ public override int ReadBlock(char[] buffer, int index, int count) => Read(buffer, index, count);
+
+#if !NETSTANDARD2_0
+ public override int Read(Span buffer)
+ {
+ int n = Math.Min(buffer.Length, _length - _pos);
+ if (n <= 0) return 0;
+ new ReadOnlySpan(_chars, _pos, n).CopyTo(buffer);
+ _pos += n;
+ return n;
+ }
+
+ public override int ReadBlock(Span buffer) => Read(buffer);
+#endif
+
+ public override string ReadToEnd()
+ {
+ var s = _pos < _length ? new string(_chars, _pos, _length - _pos) : string.Empty;
+ _pos = _length;
+ return s;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ var chars = _chars;
+ if (chars != null)
+ {
+ _chars = null;
+ _length = _pos = 0;
+ ArrayPool.Shared.Return(chars);
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj
new file mode 100644
index 0000000..04c3f9c
--- /dev/null
+++ b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj
@@ -0,0 +1,35 @@
+
+
+
+ Austin Harris
+ Austin Harris
+ Json-Rpc.Net System.Text.Json serializer
+ System.Text.Json serializer for JSON-RPC.Net. Utf8JsonReader/Utf8JsonWriter end to end; pass JsonSerializerOptions to control it.
+ 2.0.0
+ $(VersionSuffix)
+ Austin Harris
+ https://github.com/Astn/JSON-RPC.NET
+ https://github.com/Astn/JSON-RPC.NET
+ git
+ MIT
+ README.md
+ json-rpc;jsonrpc;json;rpc;server;system.text.json
+ netstandard2.0;netstandard2.1;net8.0;net10.0
+ latest
+ true
+ AustinHarris.JsonRpc.SystemTextJson
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs b/AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs
new file mode 100644
index 0000000..007b198
--- /dev/null
+++ b/AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs
@@ -0,0 +1,486 @@
+using System;
+using System.Buffers;
+using System.Buffers.Text;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using AustinHarris.JsonRpc.Serialization;
+
+namespace AustinHarris.JsonRpc.SystemTextJson
+{
+ ///
+ /// The converters that make System.Text.Json produce the wire conventions shared by every JSON-RPC.Net
+ /// serializer: whole float/double/decimal values carry ".0", DateTime is ISO-8601 as Json.NET writes it (fraction only when non-zero, trailing zeros trimmed)
+ /// and the offset, char is a one-character string, and reads accept the coercions the library has always
+ /// accepted (numeric strings, true/false as 1/0, numbers as bool, numbers as char, integers to floats).
+ ///
+ public static class JsonRpcConverters
+ {
+ /// A fresh list of the converters, in registration order.
+ public static IEnumerable Create()
+ {
+ yield return new JsonRpcNumberConverterFactory();
+ yield return new JsonRpcBooleanConverter();
+ yield return new JsonRpcCharConverter();
+ yield return new JsonRpcDateTimeConverter();
+ yield return new JsonRpcDateTimeOffsetConverter();
+ }
+
+ ///
+ /// Appends every converter whose type is not already present in . They are added
+ /// after the existing ones, so converters the caller registered keep precedence (System.Text.Json uses the
+ /// first converter that can handle a type).
+ ///
+ public static void AddMissing(JsonSerializerOptions options)
+ {
+ if (options == null) throw new ArgumentNullException(nameof(options));
+ foreach (var converter in Create())
+ {
+ if (!Contains(options, converter.GetType())) options.Converters.Add(converter);
+ }
+ }
+
+ /// True when every converter type from is registered on .
+ public static bool ContainsAll(JsonSerializerOptions options)
+ {
+ if (options == null) return false;
+ foreach (var converter in Create())
+ {
+ if (!Contains(options, converter.GetType())) return false;
+ }
+ return true;
+ }
+
+ private static bool Contains(JsonSerializerOptions options, Type converterType)
+ {
+ var converters = options.Converters;
+ for (int i = 0; i < converters.Count; i++)
+ {
+ if (converters[i].GetType() == converterType) return true;
+ }
+ return false;
+ }
+ }
+
+ /// Shared read coercions and number formatting (mirrors the built-in serializer's JsmnMapper / Utf8Json).
+ internal static class Wire
+ {
+ // ------------------------------------------------------------------ reads
+
+ public static long ReadInt64(ref Utf8JsonReader reader)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.Number:
+ if (reader.TryGetInt64(out long l)) return l;
+ return RoundToInt64(reader.GetDouble());
+ case JsonTokenType.True:
+ return 1;
+ case JsonTokenType.False:
+ return 0;
+ case JsonTokenType.String:
+ {
+ var s = reader.GetString();
+ if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out l)) return l;
+ if (s == "true") return 1;
+ if (s == "false") return 0;
+ if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return RoundToInt64(d);
+ break;
+ }
+ }
+ throw Bind(ref reader, "integer");
+ }
+
+ public static ulong ReadUInt64(ref Utf8JsonReader reader)
+ {
+ if (reader.TokenType == JsonTokenType.Number && reader.TryGetUInt64(out ulong v)) return v;
+ if (reader.TokenType == JsonTokenType.String && ulong.TryParse(reader.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return v;
+ return checked((ulong)ReadInt64(ref reader));
+ }
+
+ public static double ReadDouble(ref Utf8JsonReader reader)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.Number:
+ return reader.GetDouble();
+ case JsonTokenType.True:
+ return 1;
+ case JsonTokenType.False:
+ return 0;
+ case JsonTokenType.String:
+ {
+ var s = reader.GetString();
+ // "NaN", "Infinity", "-Infinity": what Json.NET (and every serializer here) writes for non-finite values
+ if (Utf8Json.TryParseNonFinite(s, out double d)) return d;
+ if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out d)) return d;
+ if (s == "true") return 1;
+ if (s == "false") return 0;
+ break;
+ }
+ }
+ throw Bind(ref reader, "number");
+ }
+
+ public static float ReadSingle(ref Utf8JsonReader reader)
+ {
+ if (reader.TokenType == JsonTokenType.Number) return reader.GetSingle();
+ return (float)ReadDouble(ref reader);
+ }
+
+ public static decimal ReadDecimal(ref Utf8JsonReader reader)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.Number:
+ if (reader.TryGetDecimal(out decimal m)) return m;
+ return (decimal)reader.GetDouble();
+ case JsonTokenType.True:
+ return 1;
+ case JsonTokenType.False:
+ return 0;
+ case JsonTokenType.String:
+ {
+ var s = reader.GetString();
+ if (decimal.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out m)) return m;
+ if (s == "true") return 1;
+ if (s == "false") return 0;
+ if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return (decimal)d;
+ break;
+ }
+ }
+ throw Bind(ref reader, "decimal");
+ }
+
+ public static bool ReadBoolean(ref Utf8JsonReader reader)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.True:
+ return true;
+ case JsonTokenType.False:
+ return false;
+ case JsonTokenType.Number:
+ return reader.GetDouble() != 0;
+ case JsonTokenType.String:
+ {
+ var s = reader.GetString();
+ if (bool.TryParse(s, out bool b)) return b;
+ if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return d != 0;
+ break;
+ }
+ }
+ throw Bind(ref reader, "boolean");
+ }
+
+ public static char ReadChar(ref Utf8JsonReader reader)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ var s = reader.GetString();
+ if (s.Length == 1) return s[0];
+ throw Bind(ref reader, "single character");
+ }
+ return checked((char)ReadInt64(ref reader));
+ }
+
+ public static DateTime ReadDateTime(ref Utf8JsonReader reader)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ var s = reader.GetString();
+ if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) return dt;
+ }
+ throw Bind(ref reader, "DateTime");
+ }
+
+ public static DateTimeOffset ReadDateTimeOffset(ref Utf8JsonReader reader)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ var s = reader.GetString();
+ if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dto)) return dto;
+ }
+ throw Bind(ref reader, "DateTimeOffset");
+ }
+
+ private static long RoundToInt64(double d) => checked((long)Math.Round(d, MidpointRounding.ToEven));
+
+ private static JsonException Bind(ref Utf8JsonReader reader, string expected)
+ {
+ string text;
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.String:
+ text = "\"" + reader.GetString() + "\"";
+ break;
+ case JsonTokenType.Number:
+ case JsonTokenType.True:
+ case JsonTokenType.False:
+ case JsonTokenType.Null:
+ {
+ var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
+ text = System.Text.Encoding.UTF8.GetString(span, 0, span.Length);
+ break;
+ }
+ default:
+ text = reader.TokenType.ToString();
+ break;
+ }
+ if (text.Length > 64) text = text.Substring(0, 64) + "...";
+ return new JsonException("Could not convert " + text + " to " + expected + ".");
+ }
+
+ // ------------------------------------------------------------------ writes
+
+ public static void WriteDouble(Utf8JsonWriter writer, double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value)) { WriteNonFinite(writer, value); return; }
+ Span buffer = stackalloc byte[40];
+#if NETSTANDARD2_0
+ int written = WriteAscii(value.ToString("R", CultureInfo.InvariantCulture), buffer);
+#else
+ Utf8Formatter.TryFormat(value, buffer, out int written);
+#endif
+ written = EnsureDecimalPlace(buffer, written);
+ writer.WriteRawValue(buffer.Slice(0, written), skipInputValidation: true);
+ }
+
+ public static void WriteSingle(Utf8JsonWriter writer, float value)
+ {
+ if (float.IsNaN(value) || float.IsInfinity(value)) { WriteNonFinite(writer, value); return; }
+ Span buffer = stackalloc byte[40];
+#if NETSTANDARD2_0
+ int written = WriteAscii(value.ToString("R", CultureInfo.InvariantCulture), buffer);
+#else
+ Utf8Formatter.TryFormat(value, buffer, out int written);
+#endif
+ written = EnsureDecimalPlace(buffer, written);
+ writer.WriteRawValue(buffer.Slice(0, written), skipInputValidation: true);
+ }
+
+ public static void WriteDecimal(Utf8JsonWriter writer, decimal value)
+ {
+ Span buffer = stackalloc byte[48];
+ Utf8Formatter.TryFormat(value, buffer, out int written);
+ written = EnsureDecimalPlace(buffer, written);
+ writer.WriteRawValue(buffer.Slice(0, written), skipInputValidation: true);
+ }
+
+ public static void WriteChar(Utf8JsonWriter writer, char value)
+ {
+ Span one = stackalloc char[1];
+ one[0] = value;
+ writer.WriteStringValue(one);
+ }
+
+ /// The core's (Json.NET-compatible) DateTime text, see : fraction only when non-zero, trailing zeros trimmed; Z for Utc, offset for Local, nothing for Unspecified.
+ public static void WriteDateTime(Utf8JsonWriter writer, DateTime value)
+ {
+ // The raw write bypasses the encoder so a '+' in the offset is never escaped, whatever encoder the options carry.
+ Span buffer = stackalloc byte[Utf8Json.MaxDateTimeLength + 2];
+ buffer[0] = (byte)'"';
+ int written = Utf8Json.FormatDateTime(buffer.Slice(1), value);
+ buffer[written + 1] = (byte)'"';
+ writer.WriteRawValue(buffer.Slice(0, written + 2), skipInputValidation: true);
+ }
+
+ /// Same text as , with the offset always written.
+ public static void WriteDateTimeOffset(Utf8JsonWriter writer, DateTimeOffset value)
+ {
+ Span buffer = stackalloc byte[Utf8Json.MaxDateTimeLength + 2];
+ buffer[0] = (byte)'"';
+ int written = Utf8Json.FormatDateTimeOffset(buffer.Slice(1), value);
+ buffer[written + 1] = (byte)'"';
+ writer.WriteRawValue(buffer.Slice(0, written + 2), skipInputValidation: true);
+ }
+
+ private static void WriteNonFinite(Utf8JsonWriter writer, double value)
+ {
+ // Bare NaN / Infinity are not JSON. Json.NET's default (FloatFormatHandling.String) and the built-in
+ // serializer write the quoted strings "NaN", "Infinity", "-Infinity"; keep the wire identical.
+ writer.WriteStringValue(Utf8Json.NonFiniteText(value));
+ }
+
+ private static int WriteAscii(string text, Span buffer)
+ {
+ for (int i = 0; i < text.Length; i++) buffer[i] = (byte)text[i];
+ return text.Length;
+ }
+
+ /// Appends ".0" when the formatted (finite) number has neither a fraction nor an exponent.
+ private static int EnsureDecimalPlace(Span buffer, int written)
+ {
+ for (int i = 0; i < written; i++)
+ {
+ byte b = buffer[i];
+ if (b == (byte)'.' || b == (byte)'E' || b == (byte)'e') return written;
+ }
+ buffer[written] = (byte)'.';
+ buffer[written + 1] = (byte)'0';
+ return written + 2;
+ }
+ }
+
+ ///
+ /// Replaces the built-in converters for the 11 primitive numeric types. Writes float/double/decimal with a
+ /// ".0" on whole values; reads numbers, numeric strings and true/false (as 1/0) for every numeric type, and
+ /// rounds fractional input to the nearest even integer for integer types. Nullable variants are handled by
+ /// System.Text.Json's own nullable wrapping.
+ ///
+ public sealed class JsonRpcNumberConverterFactory : JsonConverterFactory
+ {
+ public override bool CanConvert(Type typeToConvert)
+ {
+ switch (Type.GetTypeCode(typeToConvert))
+ {
+ case TypeCode.Double:
+ case TypeCode.Single:
+ case TypeCode.Decimal:
+ case TypeCode.Int32:
+ case TypeCode.Int64:
+ case TypeCode.Int16:
+ case TypeCode.Byte:
+ case TypeCode.SByte:
+ case TypeCode.UInt16:
+ case TypeCode.UInt32:
+ case TypeCode.UInt64:
+ return !typeToConvert.IsEnum;
+ default:
+ return false;
+ }
+ }
+
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ switch (Type.GetTypeCode(typeToConvert))
+ {
+ case TypeCode.Double: return DoubleConverter.Instance;
+ case TypeCode.Single: return SingleConverter.Instance;
+ case TypeCode.Decimal: return DecimalConverter.Instance;
+ case TypeCode.Int32: return Int32Converter.Instance;
+ case TypeCode.Int64: return Int64Converter.Instance;
+ case TypeCode.Int16: return Int16Converter.Instance;
+ case TypeCode.Byte: return ByteConverter.Instance;
+ case TypeCode.SByte: return SByteConverter.Instance;
+ case TypeCode.UInt16: return UInt16Converter.Instance;
+ case TypeCode.UInt32: return UInt32Converter.Instance;
+ case TypeCode.UInt64: return UInt64Converter.Instance;
+ default: throw new NotSupportedException(typeToConvert.FullName);
+ }
+ }
+
+ private sealed class DoubleConverter : JsonConverter
+ {
+ public static readonly DoubleConverter Instance = new DoubleConverter();
+ public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDouble(ref reader);
+ public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) => Wire.WriteDouble(writer, value);
+ }
+
+ private sealed class SingleConverter : JsonConverter
+ {
+ public static readonly SingleConverter Instance = new SingleConverter();
+ public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadSingle(ref reader);
+ public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) => Wire.WriteSingle(writer, value);
+ }
+
+ private sealed class DecimalConverter : JsonConverter
+ {
+ public static readonly DecimalConverter Instance = new DecimalConverter();
+ public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDecimal(ref reader);
+ public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) => Wire.WriteDecimal(writer, value);
+ }
+
+ private sealed class Int32Converter : JsonConverter
+ {
+ public static readonly Int32Converter Instance = new Int32Converter();
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int v)) return v;
+ return checked((int)Wire.ReadInt64(ref reader));
+ }
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class Int64Converter : JsonConverter
+ {
+ public static readonly Int64Converter Instance = new Int64Converter();
+ public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadInt64(ref reader);
+ public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class Int16Converter : JsonConverter
+ {
+ public static readonly Int16Converter Instance = new Int16Converter();
+ public override short Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((short)Wire.ReadInt64(ref reader));
+ public override void Write(Utf8JsonWriter writer, short value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class ByteConverter : JsonConverter
+ {
+ public static readonly ByteConverter Instance = new ByteConverter();
+ public override byte Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((byte)Wire.ReadInt64(ref reader));
+ public override void Write(Utf8JsonWriter writer, byte value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class SByteConverter : JsonConverter
+ {
+ public static readonly SByteConverter Instance = new SByteConverter();
+ public override sbyte Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((sbyte)Wire.ReadInt64(ref reader));
+ public override void Write(Utf8JsonWriter writer, sbyte value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class UInt16Converter : JsonConverter
+ {
+ public static readonly UInt16Converter Instance = new UInt16Converter();
+ public override ushort Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((ushort)Wire.ReadInt64(ref reader));
+ public override void Write(Utf8JsonWriter writer, ushort value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class UInt32Converter : JsonConverter
+ {
+ public static readonly UInt32Converter Instance = new UInt32Converter();
+ public override uint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((uint)Wire.ReadInt64(ref reader));
+ public override void Write(Utf8JsonWriter writer, uint value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+
+ private sealed class UInt64Converter : JsonConverter
+ {
+ public static readonly UInt64Converter Instance = new UInt64Converter();
+ public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadUInt64(ref reader);
+ public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
+ }
+ }
+
+ /// Reads true/false, numbers (non-zero is true) and "true"/"false"/numeric strings.
+ public sealed class JsonRpcBooleanConverter : JsonConverter
+ {
+ public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadBoolean(ref reader);
+ public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => writer.WriteBooleanValue(value);
+ }
+
+ /// Writes a one-character string; reads a one-character string or a number (the UTF-16 code unit).
+ public sealed class JsonRpcCharConverter : JsonConverter
+ {
+ public override char Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadChar(ref reader);
+ public override void Write(Utf8JsonWriter writer, char value, JsonSerializerOptions options) => Wire.WriteChar(writer, value);
+ }
+
+ ///
+ /// Writes the core DateTime text (see Utf8Json.FormatDateTime); reads any ISO-8601 text with
+ /// DateTimeStyles.RoundtripKind, so an input with an offset yields a Local DateTime.
+ ///
+ public sealed class JsonRpcDateTimeConverter : JsonConverter
+ {
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDateTime(ref reader);
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) => Wire.WriteDateTime(writer, value);
+ }
+
+ /// Writes the core DateTimeOffset text (see Utf8Json.FormatDateTimeOffset); the offset is always written.
+ public sealed class JsonRpcDateTimeOffsetConverter : JsonConverter
+ {
+ public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDateTimeOffset(ref reader);
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => Wire.WriteDateTimeOffset(writer, value);
+ }
+}
diff --git a/AustinHarris.JsonRpc.SystemTextJson/README.md b/AustinHarris.JsonRpc.SystemTextJson/README.md
new file mode 100644
index 0000000..027cbfa
--- /dev/null
+++ b/AustinHarris.JsonRpc.SystemTextJson/README.md
@@ -0,0 +1,111 @@
+# AustinHarris.JsonRpc.SystemTextJson
+
+System.Text.Json serializer for [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) 2.0. The core parses the
+JSON-RPC envelope (method / params / id) itself and asks the serializer only to convert values: request
+parameters arrive as the raw UTF-8 bytes of one JSON value and go straight into `JsonSerializer.Deserialize`
+(no transcoding, no copies); results are written with a per-thread cached `Utf8JsonWriter` directly into the
+response buffer.
+
+## Install
+
+```
+dotnet add package AustinHarris.JsonRpc.SystemTextJson
+```
+
+Targets netstandard2.0, netstandard2.1, net8.0 and net10.0; depends on System.Text.Json 10.0.x and the
+`AustinHarris.JsonRpc` core.
+
+## Use
+
+Process-wide default:
+
+```csharp
+using AustinHarris.JsonRpc;
+using AustinHarris.JsonRpc.SystemTextJson;
+
+Config.SetSerializer(new SystemTextJsonRpcSerializer());
+// or with your own options
+Config.SetSerializer(new SystemTextJsonRpcSerializer(options));
+```
+
+Per session:
+
+```csharp
+Handler.GetSessionHandler(sessionId).Serializer = new SystemTextJsonRpcSerializer(options);
+// equivalently: Config.SetSerializer(sessionId, serializer)
+```
+
+Per call (overrides both the session and the global default):
+
+```csharp
+var serializer = new SystemTextJsonRpcSerializer(options);
+string response = JsonRpcProcessor.ProcessSync(sessionId, json, context, serializer);
+// the byte-based overloads take the same trailing argument:
+JsonRpcProcessor.Process(sessionId, requestBytes, outputWriter, context, serializer);
+```
+
+Create one instance and reuse it: the instance owns nothing mutable, and its options are read-only after
+construction.
+
+## Default options
+
+`new SystemTextJsonRpcSerializer()` uses `SystemTextJsonRpcSerializer.DefaultOptions`, a single immutable
+`JsonSerializerOptions` that reproduces the wire conventions of the built-in (jsmn) and Json.NET serializers, so
+the same request yields byte-identical responses whichever serializer is installed:
+
+| Setting | Value |
+| --- | --- |
+| `WriteIndented` | `false` (compact output) |
+| `DefaultIgnoreCondition` | `Never` (nulls are written) |
+| `PropertyNamingPolicy` | `null` (member names as declared, in declaration order) |
+| `PropertyNameCaseInsensitive` | `true` |
+| `IncludeFields` | `true` (public fields bind like properties) |
+| `Encoder` | `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` (no `+` for `+`, etc.) |
+| `NumberHandling` | `AllowReadingFromString` |
+| `Converters` | the `JsonRpcConverters` below |
+
+### Converters
+
+Registered by `JsonRpcConverters.AddMissing(options)`; each is public so it can be used on its own.
+
+| Converter | Writes | Reads |
+| --- | --- | --- |
+| `JsonRpcNumberConverterFactory` (double, float, decimal, and the 8 integer types) | whole float/double/decimal values with `.0` (`3.0`, `71.0`, `0.0`), otherwise shortest round-trip (`1.2345`, `3.14159`); NaN/Infinity as bare symbols like Json.NET | numbers, numeric strings, `true`/`false` as 1/0; fractional input for integer types is rounded to even |
+| `JsonRpcBooleanConverter` | `true`/`false` | booleans, numbers (non-zero is true), `"true"`/`"false"`/numeric strings |
+| `JsonRpcCharConverter` | a one-character string | a one-character string or a number (`98` reads as `'b'`) |
+| `JsonRpcDateTimeConverter` | `yyyy-MM-ddTHH:mm:ss[.fffffff]K`, the fraction only when non-zero and with trailing zeros trimmed, exactly as Json.NET and the built-in serializer write it | any ISO-8601 text via `DateTime.Parse(..., InvariantCulture, RoundtripKind)`: an offset in the input yields a Local `DateTime` |
+| `JsonRpcDateTimeOffsetConverter` | `yyyy-MM-ddTHH:mm:ss[.fffffff]zzz` | ISO-8601 text |
+
+Nullable variants (`double?`, `DateTime?`, ...) are covered automatically by System.Text.Json's nullable
+wrapping. Because these converters replace the built-in numeric ones, `JsonNumberHandling.WriteAsString` and
+`AllowNamedFloatingPointLiterals` are not applied to the primitive numeric types.
+
+## Supplying your own options
+
+`new SystemTextJsonRpcSerializer(options)` honours the options as given (naming policy, encoder, extra
+converters, `TypeInfoResolver`, ...). The only adjustment is the converter set:
+
+- If `options.Converters` already contains every `JsonRpcConverters` type, the instance is used as-is (it is
+ made read-only, as System.Text.Json would do on first use anyway).
+- Otherwise the options are copied with `new JsonSerializerOptions(options)` and the missing converters are
+ appended to the copy. The object you passed is never mutated, so it is safe to share with other code. The
+ converters are appended *after* yours, so a converter you registered for the same type keeps precedence.
+
+`Options` returns what you passed (null for the defaults); `EffectiveOptions` returns the instance actually used.
+To start from the library defaults and tweak them:
+
+```csharp
+var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); // a mutable copy of DefaultOptions
+options.Converters.Add(new JsonStringEnumConverter());
+options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+Config.SetSerializer(new SystemTextJsonRpcSerializer(options));
+```
+
+If you build options from scratch, remember that the wire conventions above (`IncludeFields`,
+`UnsafeRelaxedJsonEscaping`, `PropertyNameCaseInsensitive`) are then up to you; only the converters are added.
+
+## Object model
+
+Pre/post-process handlers receive `JsonRequest.Params` as a `JsonElement` (the result of deserializing the
+params to `object`), and `Handler.Handle(JsonRequest)` accepts a `JsonElement` back. Conversion failures throw
+`JsonException`; the core reports them as JSON-RPC error -32603.
diff --git a/AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs b/AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs
new file mode 100644
index 0000000..6189226
--- /dev/null
+++ b/AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs
@@ -0,0 +1,281 @@
+using System;
+using System.Buffers;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using AustinHarris.JsonRpc.Serialization;
+
+namespace AustinHarris.JsonRpc.SystemTextJson
+{
+ ///
+ /// System.Text.Json-backed serializer. The core parses the JSON-RPC envelope itself and hands this class the
+ /// raw UTF-8 bytes of single values, which go straight into (no transcoding, no
+ /// copies); results are written through a per-thread cached directly into the
+ /// caller's .
+ ///
+ /// With no options the serializer uses , which reproduce the wire conventions of the
+ /// built-in serializer (see ). User options are honoured as given; the only
+ /// adjustment is that the library converters are appended when they are missing, on a copy of the options so the
+ /// instance the caller holds is never mutated (see ).
+ ///
+ public sealed class SystemTextJsonRpcSerializer : JsonRpcSerializer
+ {
+ private static readonly JsonSerializerOptions _defaultOptions = BuildDefaultOptions();
+
+ private readonly JsonSerializerOptions _options;
+
+ public SystemTextJsonRpcSerializer() : this(null) { }
+
+ ///
+ /// Options to use for every conversion, or null for . When the options do not
+ /// already carry the they are copied and the converters appended to the copy.
+ ///
+ public SystemTextJsonRpcSerializer(JsonSerializerOptions options)
+ {
+ Options = options;
+ _options = Prepare(options);
+ }
+
+ /// The options passed to the constructor (null when the library defaults are in use).
+ public JsonSerializerOptions Options { get; }
+
+ ///
+ /// The options actually used for conversions: , the constructor argument when it
+ /// already contained the library converters, or a copy of it with the converters appended.
+ ///
+ public JsonSerializerOptions EffectiveOptions => _options;
+
+ ///
+ /// The immutable options used when none are supplied: compact output, nulls written, no naming policy,
+ /// case-insensitive property matching, public fields included, ,
+ /// numbers readable from strings, plus the .
+ ///
+ public static JsonSerializerOptions DefaultOptions => _defaultOptions;
+
+ /// A fresh, mutable copy of to customise and pass back to the constructor.
+ public static JsonSerializerOptions CreateDefaultOptions() => new JsonSerializerOptions(_defaultOptions);
+
+ public override string Name => "stj";
+
+ /// The envelope reader enforces the same limit as (64 when unset).
+ public override int MaxDepth => _options.MaxDepth > 0 ? _options.MaxDepth : 64;
+
+ // ------------------------------------------------------------------ read
+
+ public override T Read(ReadOnlySpan utf8Json)
+ {
+ return JsonSerializer.Deserialize(utf8Json, TypeInfo.For(_options));
+ }
+
+ public override object Read(ReadOnlySpan utf8Json, Type type)
+ {
+ // typeof(object) yields a JsonElement: the object model pre/post-process handlers see in JsonRequest.Params.
+ return JsonSerializer.Deserialize(utf8Json, type, _options);
+ }
+
+ // ------------------------------------------------------------------ write
+
+ public override void Write(IBufferWriter output, T value)
+ {
+ var writer = RentWriter(output);
+ bool completed = false;
+ try
+ {
+ JsonSerializer.Serialize(writer, value, TypeInfo.For(_options));
+ writer.Flush();
+ completed = true;
+ }
+ finally
+ {
+ ReturnWriter(writer, completed);
+ }
+ }
+
+ public override void Write(IBufferWriter output, object value, Type type)
+ {
+ if (value == null)
+ {
+ Utf8Json.WriteNull(output);
+ return;
+ }
+ var writer = RentWriter(output);
+ bool completed = false;
+ try
+ {
+ JsonSerializer.Serialize(writer, value, type, _options);
+ writer.Flush();
+ completed = true;
+ }
+ finally
+ {
+ ReturnWriter(writer, completed);
+ }
+ }
+
+ // ------------------------------------------------------------------ writer cache
+
+ // One writer per thread, re-targeted with Reset(output) for every call. A nested Write on the same thread
+ // (a custom converter calling back into the serializer) gets a throwaway writer instead.
+ //
+ // A write that throws (a property getter, a converter, the output itself) leaves the partial value pending
+ // inside the writer. That state is discarded before the writer is released and the writer is pointed at a
+ // sink that swallows everything: the caller may dispose or rewind its output the moment the exception
+ // reaches it, so nothing may ever be flushed into that output afterwards, not even by Dispose when the
+ // cached writer is evicted because the next call uses different options.
+ [ThreadStatic] private static Utf8JsonWriter _cachedWriter;
+ [ThreadStatic] private static JsonSerializerOptions _cachedWriterOptions;
+ [ThreadStatic] private static bool _cachedWriterInUse;
+
+ private Utf8JsonWriter RentWriter(IBufferWriter output)
+ {
+ if (_cachedWriterInUse) return new Utf8JsonWriter(output, WriterOptions(_options));
+ var writer = _cachedWriter;
+ if (writer == null || !ReferenceEquals(_cachedWriterOptions, _options))
+ {
+ if (writer != null) Discard(writer);
+ writer = new Utf8JsonWriter(output, WriterOptions(_options));
+ _cachedWriter = writer;
+ _cachedWriterOptions = _options;
+ }
+ else
+ {
+ writer.Reset(output);
+ }
+ _cachedWriterInUse = true;
+ return writer;
+ }
+
+ private static void ReturnWriter(Utf8JsonWriter writer, bool completed)
+ {
+ if (ReferenceEquals(writer, _cachedWriter))
+ {
+ try
+ {
+ if (!completed) Detach(writer);
+ }
+ finally
+ {
+ _cachedWriterInUse = false;
+ }
+ }
+ else if (completed)
+ {
+ writer.Dispose();
+ }
+ else
+ {
+ Discard(writer);
+ }
+ }
+
+ /// Drops whatever the writer has pending and points it at the discarding sink, away from the caller's output.
+ private static void Detach(Utf8JsonWriter writer)
+ {
+ writer.Reset(DiscardingBufferWriter.Instance);
+ }
+
+ /// Disposes a writer without flushing anything into its previous output.
+ private static void Discard(Utf8JsonWriter writer)
+ {
+ Detach(writer);
+ writer.Dispose();
+ }
+
+ /// An that accepts and forgets everything; only ever the target of a detached writer.
+ private sealed class DiscardingBufferWriter : IBufferWriter
+ {
+ public static readonly DiscardingBufferWriter Instance = new DiscardingBufferWriter();
+
+ [ThreadStatic] private static byte[] _scratch;
+
+ private DiscardingBufferWriter() { }
+
+ public void Advance(int count) { }
+
+ public Memory GetMemory(int sizeHint = 0) => Scratch(sizeHint);
+
+ public Span GetSpan(int sizeHint = 0) => Scratch(sizeHint);
+
+ private static byte[] Scratch(int sizeHint)
+ {
+ var scratch = _scratch;
+ if (scratch == null || scratch.Length < sizeHint)
+ {
+ _scratch = scratch = new byte[Math.Max(sizeHint, 4096)];
+ }
+ return scratch;
+ }
+ }
+
+ private static JsonWriterOptions WriterOptions(JsonSerializerOptions options)
+ {
+ return new JsonWriterOptions
+ {
+ Encoder = options.Encoder,
+ Indented = options.WriteIndented,
+ IndentCharacter = options.IndentCharacter,
+ IndentSize = options.IndentSize,
+ NewLine = options.NewLine,
+ MaxDepth = options.MaxDepth,
+ SkipValidation = true
+ };
+ }
+
+ // ------------------------------------------------------------------ options
+
+ private static JsonSerializerOptions Prepare(JsonSerializerOptions options)
+ {
+ if (options == null) return _defaultOptions;
+ if (JsonRpcConverters.ContainsAll(options))
+ {
+ options.MakeReadOnly(populateMissingResolver: true);
+ return options;
+ }
+ var copy = new JsonSerializerOptions(options);
+ JsonRpcConverters.AddMissing(copy);
+ copy.MakeReadOnly(populateMissingResolver: true);
+ return copy;
+ }
+
+ private static JsonSerializerOptions BuildDefaultOptions()
+ {
+ var options = new JsonSerializerOptions
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ PropertyNamingPolicy = null,
+ DictionaryKeyPolicy = null,
+ PropertyNameCaseInsensitive = true,
+ IncludeFields = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString
+ };
+ JsonRpcConverters.AddMissing(options);
+ options.MakeReadOnly(populateMissingResolver: true);
+ return options;
+ }
+
+ /// Caches the root JsonTypeInfo for the most recently used options per T (skips the options' own lookup).
+ private static class TypeInfo
+ {
+ private static Entry _last;
+
+ public static JsonTypeInfo For(JsonSerializerOptions options)
+ {
+ var entry = _last;
+ if (entry != null && ReferenceEquals(entry.Options, options)) return entry.Info;
+ var info = (JsonTypeInfo)options.GetTypeInfo(typeof(T));
+ _last = new Entry(options, info);
+ return info;
+ }
+
+ private sealed class Entry
+ {
+ public readonly JsonSerializerOptions Options;
+ public readonly JsonTypeInfo Info;
+ public Entry(JsonSerializerOptions options, JsonTypeInfo info) { Options = options; Info = info; }
+ }
+ }
+ }
+}
diff --git a/AustinHarris.JsonRpc.sln b/AustinHarris.JsonRpc.sln
index 6cf1c28..3433a7c 100644
--- a/AustinHarris.JsonRpc.sln
+++ b/AustinHarris.JsonRpc.sln
@@ -11,16 +11,30 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpcTestN",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServer_Console", "TestServer_Console\TestServer_Console.csproj", "{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpc.Newtonsoft", "AustinHarris.JsonRpc.Newtonsoft\AustinHarris.JsonRpc.Newtonsoft.csproj", "{3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Json-Rpc", "Json-Rpc", "{BE151132-B0EC-9FBA-04DB-B01036AA7C5A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpc.SystemTextJson", "AustinHarris.JsonRpc.SystemTextJson\AustinHarris.JsonRpc.SystemTextJson.csproj", "{041EBECC-308F-4AA0-A91E-5FC9FD897BFB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpc.AspNetCore", "AustinHarris.JsonRpc.AspNetCore\AustinHarris.JsonRpc.AspNetCore.csproj", "{58FAA243-3EF2-4125-A847-663002802812}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmHost", "samples\WasmHost\WasmHost.csproj", "{293EB57E-ED38-4127-879C-34FD73820127}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|ARM = Debug|ARM
Debug|Mixed Platforms = Debug|Mixed Platforms
Debug|x86 = Debug|x86
+ Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
Release|ARM = Release|ARM
Release|Mixed Platforms = Release|Mixed Platforms
Release|x86 = Release|x86
+ Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -30,13 +44,17 @@ Global
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x86.ActiveCfg = Debug|x86
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x86.Build.0 = Debug|x86
+ {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x64.Build.0 = Debug|Any CPU
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Any CPU.ActiveCfg = Release|Any CPU
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Any CPU.Build.0 = Release|Any CPU
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|ARM.ActiveCfg = Release|x86
- {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.ActiveCfg = Release|x86
- {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.Build.0 = Release|x86
+ {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x86.ActiveCfg = Release|x86
{24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x86.Build.0 = Release|x86
+ {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x64.ActiveCfg = Release|Any CPU
+ {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x64.Build.0 = Release|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|ARM.ActiveCfg = Debug|Any CPU
@@ -45,6 +63,8 @@ Global
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x86.ActiveCfg = Debug|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x86.Build.0 = Debug|Any CPU
+ {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x64.Build.0 = Debug|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|Any CPU.Build.0 = Release|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|ARM.ActiveCfg = Release|Any CPU
@@ -53,6 +73,8 @@ Global
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x86.ActiveCfg = Release|Any CPU
{8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x86.Build.0 = Release|Any CPU
+ {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x64.ActiveCfg = Release|Any CPU
+ {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x64.Build.0 = Release|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|ARM.ActiveCfg = Debug|Any CPU
@@ -61,6 +83,8 @@ Global
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x86.ActiveCfg = Debug|x86
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x86.Build.0 = Debug|x86
+ {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x64.Build.0 = Debug|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|Any CPU.Build.0 = Release|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|ARM.ActiveCfg = Release|Any CPU
@@ -69,10 +93,95 @@ Global
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x86.ActiveCfg = Release|x86
{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x86.Build.0 = Release|x86
+ {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x64.ActiveCfg = Release|Any CPU
+ {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x64.Build.0 = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|ARM.Build.0 = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x86.Build.0 = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x64.Build.0 = Debug|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|ARM.ActiveCfg = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|ARM.Build.0 = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x86.ActiveCfg = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x86.Build.0 = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x64.ActiveCfg = Release|Any CPU
+ {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x64.Build.0 = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|ARM.Build.0 = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x86.Build.0 = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x64.Build.0 = Debug|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|ARM.ActiveCfg = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|ARM.Build.0 = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x86.ActiveCfg = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x86.Build.0 = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x64.ActiveCfg = Release|Any CPU
+ {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x64.Build.0 = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|ARM.Build.0 = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|x86.Build.0 = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Debug|x64.Build.0 = Debug|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|Any CPU.Build.0 = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|ARM.ActiveCfg = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|ARM.Build.0 = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|x86.ActiveCfg = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|x86.Build.0 = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|x64.ActiveCfg = Release|Any CPU
+ {58FAA243-3EF2-4125-A847-663002802812}.Release|x64.Build.0 = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|ARM.Build.0 = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x86.Build.0 = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x64.Build.0 = Debug|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|Any CPU.Build.0 = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|ARM.ActiveCfg = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|ARM.Build.0 = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|x86.ActiveCfg = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|x86.Build.0 = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|x64.ActiveCfg = Release|Any CPU
+ {293EB57E-ED38-4127-879C-34FD73820127}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {293EB57E-ED38-4127-879C-34FD73820127} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ EndGlobalSection
GlobalSection(TestCaseManagementSettings) = postSolution
CategoryFile = AustinHarris.JsonRpc.vsmdi
EndGlobalSection
diff --git a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs
new file mode 100644
index 0000000..c9cad93
--- /dev/null
+++ b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs
@@ -0,0 +1,440 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading.Tasks;
+using System.Threading;
+using Microsoft.AspNetCore.Http;
+using Newtonsoft.Json.Linq;
+using AustinHarris.JsonRpc;
+using AustinHarris.JsonRpc.AspNetCore;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Connections;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+
+namespace AustinHarris.JsonRpcTestN
+{
+ /// A service built by the container (constructor dependency) rather than deriving from JsonRpcService.
+ public class DiEchoService
+ {
+ private readonly ILogger _log;
+
+ public DiEchoService(ILogger log)
+ {
+ _log = log;
+ }
+
+ [JsonRpcMethod("di.echo")]
+ public string Echo(string s)
+ {
+ _log.LogDebug("echo {S}", s);
+ return s;
+ }
+
+ [JsonRpcMethod("di.context")]
+ public string ContextTypeName()
+ {
+ return JsonRpcContext.Current().Value?.GetType().Name;
+ }
+ }
+
+ /// Kestrel end to end: HTTP endpoint (PipeReader in, BodyWriter out) and JSON-RPC over a raw TCP connection.
+ [TestFixture]
+ [NonParallelizable]
+ public class AspNetCoreTests
+ {
+ private WebApplication _app;
+ private HttpClient _http;
+ private int _tcpPort;
+
+ [OneTimeSetUp]
+ public async Task StartHost()
+ {
+ _ = new CalculatorService(); // binds the shared test service to the default session (idempotent)
+
+ _tcpPort = FreePort();
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(k =>
+ {
+ k.Listen(IPAddress.Loopback, 0);
+ k.Listen(IPAddress.Loopback, _tcpPort, l => l.UseConnectionHandler());
+ });
+ builder.Services.AddJsonRpc();
+ builder.Services.AddJsonRpcService();
+
+ _app = builder.Build();
+ _app.MapJsonRpc("/rpc");
+ await _app.StartAsync();
+
+ var addresses = _app.Services.GetRequiredService().Features.Get().Addresses;
+ var httpAddress = addresses.First(a => !a.EndsWith(":" + _tcpPort));
+ _http = new HttpClient { BaseAddress = new Uri(httpAddress) };
+ }
+
+ [OneTimeTearDown]
+ public async Task StopHost()
+ {
+ _http?.Dispose();
+ if (_app != null)
+ {
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+ }
+
+ private static int FreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private async Task PostAsync(string json)
+ {
+ return await _http.PostAsync("/rpc", new StringContent(json, Encoding.UTF8, "application/json"));
+ }
+
+ [Test]
+ public async Task Http_SingleRequest_IsAnsweredAsJson()
+ {
+ var response = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""IntToInt"",""params"":[5],""id"":1}");
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("application/json", response.Content.Headers.ContentType.MediaType);
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":5,\"id\":1}", await response.Content.ReadAsStringAsync());
+ }
+
+ [Test]
+ public async Task Http_Notification_Is204()
+ {
+ var response = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""Notify"",""params"":[""hi""]}");
+ Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.AreEqual(string.Empty, await response.Content.ReadAsStringAsync());
+ }
+
+ [Test]
+ public async Task Http_Batch_IsAnsweredAsArray()
+ {
+ var response = await PostAsync(@"[{""jsonrpc"":""2.0"",""method"":""IntToInt"",""params"":[1],""id"":1},{""jsonrpc"":""2.0"",""method"":""IntToInt"",""params"":[2],""id"":2}]");
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1},{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}]", await response.Content.ReadAsStringAsync());
+ }
+
+ [Test]
+ public async Task Http_ParseError_IsReportedInBody()
+ {
+ var response = await PostAsync("{not json");
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ StringAssert.Contains("-32700", await response.Content.ReadAsStringAsync());
+ }
+
+ [Test]
+ public async Task Http_Get_IsMethodNotAllowed()
+ {
+ var response = await _http.GetAsync("/rpc");
+ Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode);
+ }
+
+ [Test]
+ public async Task Http_DiService_IsBoundAndSeesHttpContext()
+ {
+ var echo = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""di.echo"",""params"":[""abc""],""id"":7}");
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"abc\",\"id\":7}", await echo.Content.ReadAsStringAsync());
+
+ var ctx = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""di.context"",""id"":8}");
+ StringAssert.Contains("HttpContext", await ctx.Content.ReadAsStringAsync());
+ }
+
+ [Test]
+ public async Task Http_LargeBody_Is413()
+ {
+ var big = "{\"jsonrpc\":\"2.0\",\"method\":\"internal.echo\",\"params\":[\"" + new string('x', 5 * 1024 * 1024) + "\"],\"id\":1}";
+ var response = await PostAsync(big);
+ Assert.AreEqual(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
+ }
+
+ [Test]
+ public async Task Tcp_TwoDocumentsInOneWrite_AreAnsweredInOrder()
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, _tcpPort);
+ using var stream = client.GetStream();
+ var payload = Encoding.UTF8.GetBytes("{\"method\":\"IntToInt\",\"params\":[1],\"id\":1}\n{\"method\":\"IntToInt\",\"params\":[2],\"id\":2}\n");
+ await stream.WriteAsync(payload, 0, payload.Length);
+
+ var text = await ReadUntilAsync(stream, "\"id\":2}");
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}", text);
+ }
+
+ [Test]
+ public async Task Tcp_DocumentSplitAcrossWrites_IsReassembled()
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, _tcpPort);
+ using var stream = client.GetStream();
+ var first = Encoding.UTF8.GetBytes("{\"method\":\"internal.echo\",\"params\":[\"sp");
+ var second = Encoding.UTF8.GetBytes("lit\"],\"id\":3}");
+ await stream.WriteAsync(first, 0, first.Length);
+ await stream.FlushAsync();
+ await Task.Delay(100);
+ await stream.WriteAsync(second, 0, second.Length);
+
+ var text = await ReadUntilAsync(stream, "\"id\":3}");
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"split\",\"id\":3}", text);
+ }
+
+ // ------------------------------------------------------------------ DI binding honours JsonRpcOptions.SessionId
+
+ /// A JsonRpcService subclass built by DI: its base constructor binds it to the default session.
+ public class TenantAutoService : JsonRpcService
+ {
+ [JsonRpcMethod("tenant.ping")]
+ public int Ping() => 7;
+ }
+
+ /// A plain DI service in the same host, bound to the configured session by the binder.
+ public class TenantPlainService
+ {
+ [JsonRpcMethod("tenant.echo")]
+ public string Echo(string s) => s;
+ }
+
+ [Test]
+ public async Task Http_JsonRpcServiceSubclass_IsBoundToTheConfiguredSession()
+ {
+ const string session = "aspnetcore-tenant";
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0));
+ builder.Services.AddJsonRpc(o => o.SessionId = session);
+ builder.Services.AddJsonRpcService();
+ builder.Services.AddJsonRpcService();
+
+ var app = builder.Build();
+ app.MapJsonRpc("/rpc");
+ await app.StartAsync();
+ try
+ {
+ var address = app.Services.GetRequiredService().Features.Get().Addresses.First();
+ using var http = new HttpClient { BaseAddress = new Uri(address) };
+
+ // the subclass answers in the configured session (it used to be skipped by the binder: -32601)
+ var ping = await http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""tenant.ping"",""id"":1}", Encoding.UTF8, "application/json"));
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", await ping.Content.ReadAsStringAsync());
+
+ var echo = await http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""tenant.echo"",""params"":[""t""],""id"":2}", Encoding.UTF8, "application/json"));
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"t\",\"id\":2}", await echo.Content.ReadAsStringAsync());
+
+ // both live in the configured session's method table
+ var handler = Handler.GetSessionHandler(session);
+ Assert.IsTrue(handler.MetaData.Services.ContainsKey("tenant.ping"));
+ Assert.IsTrue(handler.MetaData.Services.ContainsKey("tenant.echo"));
+
+ // the plain service is not exposed in the default session
+ var defaultResponse = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""tenant.echo"",""params"":[""t""],""id"":3}");
+ StringAssert.Contains("-32601", await defaultResponse.Content.ReadAsStringAsync());
+ }
+ finally
+ {
+ await app.StopAsync();
+ await app.DisposeAsync();
+ Handler.DestroySession(session);
+ }
+ }
+
+ private static async Task ReadUntilAsync(NetworkStream stream, string terminator)
+ {
+ var sb = new StringBuilder();
+ var buffer = new byte[4096];
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (DateTime.UtcNow < deadline)
+ {
+ var readTask = stream.ReadAsync(buffer, 0, buffer.Length);
+ if (await Task.WhenAny(readTask, Task.Delay(5000)) != readTask) break;
+ int n = await readTask;
+ if (n == 0) break;
+ sb.Append(Encoding.UTF8.GetString(buffer, 0, n));
+ if (sb.ToString().EndsWith(terminator)) break;
+ }
+ return sb.ToString();
+ }
+ }
+
+ [TestFixture]
+ [NonParallelizable]
+ public sealed class AsyncAspNetCoreTests
+ {
+ private WebApplication _app;
+ private HttpClient _http;
+ private int _port;
+ private string _session;
+ private AsyncHostService _service;
+
+ public sealed class AsyncHostService
+ {
+ internal TaskCompletionSource Gate;
+ internal TaskCompletionSource Started;
+ internal TaskCompletionSource Canceled;
+ [JsonRpcMethod("fast")] public Task Fast(int value) => Task.FromResult(value);
+ [JsonRpcMethod("slow")]
+ public async Task Slow([JsonRpcCancellation] CancellationToken token)
+ {
+ Started.TrySetResult(1);
+ return await Gate.Task.WaitAsync(token);
+ }
+ [JsonRpcMethod("disconnect")]
+ public async Task Disconnect([JsonRpcCancellation] CancellationToken token)
+ {
+ Started.TrySetResult(1);
+ try { await Task.Delay(Timeout.Infinite, token); }
+ catch (OperationCanceledException) { Canceled.TrySetResult(1); throw; }
+ return 1;
+ }
+ }
+
+ private static TaskCompletionSource NewGate() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ [SetUp]
+ public async Task StartAsyncHost()
+ {
+ _session = "async-host-" + Guid.NewGuid().ToString("N");
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start(); _port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop();
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(k =>
+ {
+ k.Listen(IPAddress.Loopback, 0);
+ k.Listen(IPAddress.Loopback, _port, l => l.UseConnectionHandler());
+ });
+ _service = new AsyncHostService { Gate = NewGate(), Started = NewGate(), Canceled = NewGate() };
+ builder.Services.AddSingleton(_service);
+ builder.Services.AddJsonRpc(o => { o.SessionId = _session; o.EnableAsyncMethods = true; });
+ builder.Services.AddJsonRpcService();
+ _app = builder.Build();
+ _app.MapJsonRpc("/rpc");
+ _app.MapJsonRpc("/empty200", new JsonRpcOptions { SessionId = _session, EnableAsyncMethods = true, NoContentForNotifications = false });
+ await _app.StartAsync();
+ var address = _app.Services.GetRequiredService().Features.Get().Addresses.First(a => !a.EndsWith(":" + _port));
+ _http = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(5) };
+ }
+
+ [TearDown]
+ public async Task StopAsyncHost()
+ {
+ _service.Gate.TrySetResult(7);
+ _http?.Dispose();
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ Handler.DestroySession(_session);
+ }
+
+ private Task Post(string json, string path = "/rpc", CancellationToken token = default) => _http.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"), token);
+
+ [Test]
+ public async Task Http_AwaitsSuspendedDiMethod()
+ {
+ var pending = Post("{\"method\":\"slow\",\"id\":1}");
+ await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.IsFalse(pending.IsCompleted);
+ _service.Gate.SetResult(7);
+ using var response = await pending;
+ Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
+ Assert.AreEqual(7, (int)JObject.Parse(await response.Content.ReadAsStringAsync())["result"]);
+ }
+
+ [TestCase("/rpc", HttpStatusCode.NoContent)]
+ [TestCase("/empty200", HttpStatusCode.OK)]
+ public async Task Http_NotificationsKeepStatusAndAreAwaited(string path, HttpStatusCode status)
+ {
+ var pending = Post("{\"method\":\"slow\"}", path);
+ await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.IsFalse(pending.IsCompleted);
+ _service.Gate.SetResult(7);
+ using var response = await pending;
+ Assert.AreEqual(status, response.StatusCode);
+ Assert.AreEqual("", await response.Content.ReadAsStringAsync());
+ }
+
+ [Test]
+ public async Task Tcp_FlushesCompletedRepliesBeforeSlowDocument_AndKeepsOrder()
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, _port);
+ using var stream = client.GetStream();
+ await stream.WriteAsync(Encoding.UTF8.GetBytes("{\"method\":\"fast\",\"params\":[1],\"id\":1}{\"method\":\"fast\",\"params\":[2],\"id\":2}{\"method\":\"slow\",\"id\":3}{\"method\":\"fast\",\"params\":[4],\"id\":4}"));
+ await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var early = await ReadThrough(stream, "\"id\":2}");
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}", early);
+ Assert.IsFalse(_service.Gate.Task.IsCompleted);
+ _service.Gate.SetResult(7);
+ var later = await ReadThrough(stream, "\"id\":4}");
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":3}{\"jsonrpc\":\"2.0\",\"result\":4,\"id\":4}", later);
+ }
+
+ [TestCase(false)] [TestCase(true)]
+ public async Task Disconnect_CancelsInvocation_AndHostRemainsAvailable(bool tcp)
+ {
+ if (tcp)
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, _port);
+ await client.GetStream().WriteAsync(Encoding.UTF8.GetBytes("{\"method\":\"disconnect\",\"id\":1}"));
+ await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ client.Client.LingerState = new LingerOption(true, 0);
+ client.Close();
+ }
+ else
+ {
+ using var cts = new CancellationTokenSource();
+ var pending = Post("{\"method\":\"disconnect\",\"id\":1}", token: cts.Token);
+ await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ cts.Cancel();
+ try { await pending; Assert.Fail("expected client cancellation"); } catch (OperationCanceledException) { }
+ }
+ await _service.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ using var response = await Post("{\"method\":\"fast\",\"params\":[7],\"id\":2}");
+ Assert.AreEqual(7, (int)JObject.Parse(await response.Content.ReadAsStringAsync())["result"]);
+ }
+
+ public sealed class InvalidAsyncDiService
+ {
+ [JsonRpcMethod] public async void Invalid() => await Task.Yield();
+ }
+
+ [Test]
+ public async Task DiRegistration_RejectsAsyncVoid()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.Services.AddJsonRpc(o => o.SessionId = _session);
+ builder.Services.AddJsonRpcService();
+ await using var app = builder.Build();
+ Assert.ThrowsAsync(async () => await app.StartAsync());
+ }
+
+ private static async Task ReadThrough(NetworkStream stream, string terminator)
+ {
+ var text = new StringBuilder();
+ var bytes = new byte[4096];
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ while (!text.ToString().EndsWith(terminator, StringComparison.Ordinal))
+ {
+ int count = await stream.ReadAsync(bytes.AsMemory(), cts.Token);
+ if (count == 0) break;
+ text.Append(Encoding.UTF8.GetString(bytes, 0, count));
+ }
+ return text.ToString();
+ }
+ }
+
+}
diff --git a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs
new file mode 100644
index 0000000..82f9173
--- /dev/null
+++ b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs
@@ -0,0 +1,736 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Threading.Tasks.Sources;
+using AustinHarris.JsonRpc;
+using AustinHarris.JsonRpc.Invocation;
+using AustinHarris.JsonRpc.Serialization;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+
+namespace AustinHarris.JsonRpcTestN
+{
+ [TestFixture]
+ public sealed class AsyncInvocationTests
+ {
+ private string _session;
+ private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" };
+ [SetUp] public void SetUp() => _session = "async-" + Guid.NewGuid().ToString("N");
+ [TearDown] public void TearDown() => Handler.DestroySession(_session);
+ private void Bind(string name, Delegate method, RpcContextFlow flow = RpcContextFlow.Flow) => ServiceBinder.BindMethod(_session, name, method, contextFlow: flow);
+ private Task Run(string json, JsonRpcSerializer serializer = null, object context = null, CancellationToken token = default) => JsonRpcProcessor.ProcessAsync(_session, json, context, serializer, token);
+ private static TaskCompletionSource Gate() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ private static string Request(string method, string parameters = null, string id = "1") => "{\"method\":\"" + method + "\"" + (parameters == null ? "" : ",\"params\":" + parameters) + (id == null ? "" : ",\"id\":" + id) + "}";
+ private static void Error(string json, int code) => Assert.AreEqual(code, (int)JObject.Parse(json)["error"]["code"], json);
+
+ private static IEnumerable Shapes()
+ {
+ foreach (var serializer in Serializers)
+ foreach (var shape in new[] { "task", "taskResult", "valueTask", "valueTaskResult" })
+ for (int mode = 0; mode < 5; mode++)
+ foreach (bool hooks in new[] { false, true })
+ yield return new TestCaseData(serializer, shape, mode, hooks);
+ }
+
+ private static Task TaskResult(int mode)
+ {
+ switch (mode)
+ {
+ case 1: throw new InvalidOperationException("synchronous throw");
+ case 2: return Task.FromException(new InvalidOperationException("completed fault"));
+ case 3: return Task.FromCanceled(new CancellationToken(true));
+ case 4: return YieldResult();
+ default: return Task.FromResult(7);
+ }
+ }
+ private static async Task YieldResult() { await Task.Yield(); return 7; }
+ private static Task PlainTask(int mode) => TaskResult(mode);
+ private static ValueTask ValueTaskResult(int mode) => mode == 0 ? new ValueTask(7) : new ValueTask(TaskResult(mode));
+ private static ValueTask PlainValueTask(int mode) => mode == 0 ? default : new ValueTask(TaskResult(mode));
+
+ [TestCaseSource(nameof(Shapes))]
+ public async Task AllAwaitableShapes(string serializerName, string shape, int mode, bool hooks)
+ {
+ Delegate method = shape == "task" ? (Delegate)new Func(PlainTask)
+ : shape == "taskResult" ? new Func>(TaskResult)
+ : shape == "valueTask" ? new Func(PlainValueTask)
+ : new Func>(ValueTaskResult);
+ Bind("run", method);
+ int pre = 0, post = 0, errors = 0;
+ if (hooks)
+ {
+ Handler.GetSessionHandler(_session).SetPreProcessHandler((r, c) => { pre++; return null; });
+ Handler.GetSessionHandler(_session).SetPostProcessHandler((r, response, c) => { post++; return null; });
+ Config.SetErrorHandler(_session, (r, e) => { errors++; return e; });
+ }
+ var json = await Run(Request("run", "[" + mode + "]"), SerializerCatalog.Create(serializerName));
+ if (mode == 0 || mode == 4)
+ {
+ var result = JObject.Parse(json)["result"];
+ if (shape.EndsWith("Result")) Assert.AreEqual(7, (int)result);
+ else Assert.AreEqual(JTokenType.Null, result.Type);
+ }
+ else Error(json, -32603);
+ if (hooks) { Assert.AreEqual(1, pre); Assert.AreEqual(1, post); Assert.AreEqual(mode > 0 && mode < 4 ? 1 : 0, errors); }
+ }
+
+ private sealed class OnceSource : IValueTaskSource
+ {
+ private ManualResetValueTaskSourceCore _core;
+ internal int Consumed;
+ internal OnceSource() { _core.RunContinuationsAsynchronously = true; }
+ internal ValueTask Value => new ValueTask(this, _core.Version);
+ internal void Complete() => _core.SetResult(7);
+ public int GetResult(short token) { if (Interlocked.Increment(ref Consumed) != 1) throw new InvalidOperationException("consumed twice"); return _core.GetResult(token); }
+ public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);
+ public void OnCompleted(Action