Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
name: Benchmark explorer (GitHub Pages)
name: Documentation site (GitHub Pages)
on:
push:
branches: [ master ]
paths:
- "README.md"
- "docs/**"
- "site/**"
- "AustinHarris.JsonRpc.AspNetCore/README.md"
- "AustinHarris.JsonRpc.Newtonsoft/README.md"
- "AustinHarris.JsonRpc.SystemTextJson/README.md"
- "samples/WasmHost/README.md"
- "benchmarks/Micro/README.md"
- "benchmarks/charts/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
Expand All @@ -24,17 +32,23 @@ jobs:
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.
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- uses: typst-community/setup-typst@v5
with:
typst-version: "0.14.2"
cache-dependency-path: site/page.typ
- run: pip install pygments==2.19.2
# The committed charts 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/
# Every documentation page is rendered from its Markdown source; a page that fails to compile fails here.
- name: Build the site
run: python3 site/build.py --out site/_out
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: site
path: site/_out
- id: deployment
uses: actions/deploy-pages@v5
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,6 @@ UpgradeLog*.XML

# Python renderer caches
__pycache__/

# Documentation site build output (site/build.py)
site/_out/
115 changes: 90 additions & 25 deletions AustinHarris.JsonRpc.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ UTF-8 bytes, so the HTTP endpoint reads the body with `PipeReader` and writes st
`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).

## Install

```
dotnet add package AustinHarris.JsonRpc.AspNetCore
```

Targets `net8.0` and `net10.0`; depends on the `AustinHarris.JsonRpc` core package and the ASP.NET Core shared
framework.

## HTTP endpoint

```csharp
Expand All @@ -31,15 +40,16 @@ public class CalculatorService
public CalculatorService(ILogger<CalculatorService> log) => _log = log;

[JsonRpcMethod]
public double add(double l, double r) => l + r;
public double add(double l, double r)
{
_log.LogDebug("add {L} {R}", l, r);
return 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<T>()` 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:
Expand All @@ -48,35 +58,90 @@ the middleware pipeline compose with it:
app.MapJsonRpc("/rpc").RequireAuthorization("api");
```

## Raw connection (TCP)
`MapJsonRpc` adds no authorization, TLS requirement, rate limit or request deadline by itself; apply those
policies explicitly. `MaxRequestBytes` limits the HTTP body, but there is no batch-count or response-size limit.
Keep `Config.IncludeExceptionDetails` off for untrusted clients; the default still sends an unhandled exception's
CLR type name and message, see [Exception disclosure](https://github.com/Astn/JSON-RPC.NET#exception-disclosure)
in the main README.

`MapJsonRpc(pattern = "/jsonrpc", options = null)` uses the options from `AddJsonRpc` unless you pass your own,
so two endpoints can serve two sessions, for example a strict API next to a lenient one for older clients:

```csharp
app.MapJsonRpc("/rpc");
app.MapJsonRpc("/legacy", new JsonRpcOptions { SessionId = "legacy-clients", Serializer = new NewtonsoftJsonRpcSerializer() });
```

## Services and lifetime

`AddJsonRpcService<T>()` registers `T` as a singleton unless `T` is already registered. When the host starts,
each registered service is resolved once from the root container and bound; that one instance then serves every
HTTP request and every raw connection, concurrently. So `T` and its dependencies must be thread-safe, and `T`
cannot take scoped dependencies such as an EF Core `DbContext`: with scope validation on, the host fails at
startup; with it off, the dependency leaks. For per-request services, resolve them inside the method from
`((HttpContext)Handler.RpcContext()).RequestServices` on HTTP; a raw connection's context is the
`ConnectionContext`, which has no request scope. Do not inject request-scoped state into a service; read
per-request data from the context instead.

`AddJsonRpcServicesFromAssembly(assembly)` does the same for every non-abstract class in the assembly that
declares a `[JsonRpcMethod]`. Private methods count, so the attribute is the whole access list, and an MVC
controller that carries it becomes a singleton too.

A class deriving from `JsonRpcService` binds itself to the default session in its constructor. Registering it here
as well is harmless when the effective session is the default. With `SessionId` set, the host binds it to that
session in addition, so it stays reachable on the default session too.

## Raw connection (TCP, Unix socket, named pipe)

```csharp
builder.WebHost.ConfigureKestrel(k =>
{
k.ListenAnyIP(9000, l => l.UseConnectionHandler<JsonRpcConnectionHandler>());
k.ListenLocalhost(9000, l => l.UseConnectionHandler<JsonRpcConnectionHandler>());
// k.ListenUnixSocket("/tmp/rpc.sock", l => l.UseConnectionHandler<JsonRpcConnectionHandler>());
// k.ListenNamedPipe("rpc", l => l.UseConnectionHandler<JsonRpcConnectionHandler>());
});
```

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.
Clients write JSON documents back to back (whitespace or newlines between them are fine) and read the responses
in the same order, also back to back with no newline or `Content-Length` prefix, so the client must parse one
complete JSON value at a time. Notifications produce nothing. The `ConnectionContext` is the RPC context.

The framer accepts strict JSON only, even with the Json.NET serializer or a lenient `JsmnSerializer` selected.
A document larger than `MaxRequestBytes` aborts the connection. Documents on one connection are processed one at
a time, in order; separate connections run concurrently.

A raw connection does not pass through the HTTP middleware pipeline, so it has no authentication, authorisation
or rate limiting. Listen on loopback or a Unix socket, or configure transport security, authentication and
connection limits at the Kestrel listener or in a surrounding protocol.

## Async methods

Set `EnableAsyncMethods = true` to serve `Task` and `ValueTask` methods through `ProcessAsync`. With it off (the
default), requests are processed synchronously and an async method is answered with `-32603` without being
invoked.

- **HTTP:** the call is cancelled when the client disconnects (`HttpContext.RequestAborted`). Notifications are
awaited and still answer `204`. The body reader stays leased until the invocation finishes.
- **Raw connections:** documents are processed one at a time, in order. Replies already finished are flushed
before the connection waits on a slow method. When the connection closes, the running method is waited for and
its response discarded.

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.
A method receives the token by declaring a `[JsonRpcCancellation] CancellationToken` parameter; see
[Asynchronous methods and cancellation](https://github.com/Astn/JSON-RPC.NET#asynchronous-methods-and-cancellation)
in the main README.

## 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 |
| Option | Default | Scope | Meaning |
|---|---|---|---|
| `EnableAsyncMethods` | false | HTTP and raw | use `ProcessAsync` for `Task`/`ValueTask` methods, with host cancellation |
| `SessionId` | default session | HTTP and raw | which session's methods answer |
| `SessionSelector` | null | HTTP | pick the session per request from the `HttpContext`; it must map to a fixed set of ids, because an unknown id creates a session that persists |
| `Serializer` | session, then `Config.Serializer` | HTTP and raw | serializer for this host |
| `ContextFactory` | `HttpContext` | HTTP | what `JsonRpcContext.Current()` returns |
| `MaxRequestBytes` | 4 MB | HTTP body, or one raw document | larger bodies get 413; a larger raw document aborts the connection |
| `ResponseContentType` | `application/json` | HTTP | |
| `NoContentForNotifications` | true | HTTP | 204 for notifications, otherwise 200 with an empty body |

For raw connections the RPC context is always the `ConnectionContext`; `SessionSelector`, `ContextFactory`,
`ResponseContentType` and `NoContentForNotifications` are not used.
37 changes: 25 additions & 12 deletions AustinHarris.JsonRpc.Newtonsoft/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

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.
The core package (`AustinHarris.JsonRpc`) parses the JSON-RPC envelope itself and ships a built-in serializer for
parameters and results that needs no JSON library. 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
```

Targets `netstandard2.0`, `netstandard2.1`, `net8.0` and `net10.0`; depends on Newtonsoft.Json 13.0.4 and the
`AustinHarris.JsonRpc` core package.

## Choosing the serializer

Process-wide (every session that does not override it):
Expand All @@ -27,8 +30,7 @@ Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings));
Per session:

```csharp
Handler.GetSessionHandler("session-42").Serializer = new NewtonsoftJsonRpcSerializer(settings);
// or: Config.SetSerializer("session-42", new NewtonsoftJsonRpcSerializer(settings));
Config.SetSerializer("session-42", new NewtonsoftJsonRpcSerializer(settings));
```

Per call (overrides both):
Expand All @@ -38,8 +40,14 @@ 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.
When each level is the right one is covered in
[docs/serializers.md](https://github.com/Astn/JSON-RPC.NET/blob/master/docs/serializers.md).

Create the serializer once and reuse it: it holds one `JsonSerializer` built from the settings, and the processor's
synchronous path keeps an envelope reader in per-thread scratch storage for as long as the serializer instance
stays the same, so a new serializer per call throws that reuse away. That `JsonSerializer`, its converters,
contract resolver and callbacks are used concurrently by unrelated requests: configure the instance before serving
traffic, do not mutate it while requests are in flight, and make custom components thread-safe.

## Settings-based helpers (1.x compatibility)

Expand All @@ -59,15 +67,20 @@ Each distinct settings instance is turned into a serializer the first time it is
* 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.
values, ISO-8601 dates (fraction only when non-zero, trailing zeros trimmed; `Z` for UTC, the offset for Local,
nothing for Unspecified), `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.
Leniency applies to the HTTP endpoint and to in-process calls; on a raw Kestrel connection the framer that
splits the stream into documents accepts strict JSON only.
* `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`.
A value Json.NET cannot convert to the parameter's type (a `JsonException`, or a format, overflow or cast
exception while reading an argument) is reported as `-32602 Invalid params`, with `data` naming the parameter and
the expected type; the value sent is never echoed. A type the serializer cannot handle at all, or an exception
inside your method, is `-32603 Internal error`.

## Performance notes

Expand Down
Loading
Loading