<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged aspnetcore</title>
<link>https://coderlegion.com/tag/aspnetcore</link>
<description>Powered by Question2Answer</description>
<item>
<title>A JSON-RPC Library With No Method Registration: How Does It Decide What Can Be Called?</title>
<link>https://coderlegion.com/32964/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called</link>
<description>&lt;p&gt;JSON-RPC 2.0 is a small specification: the client sends &lt;code&gt;{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;method&quot;:&quot;...&quot;,&quot;params&quot;:{...},&quot;id&quot;:1}&lt;/code&gt;, and the server answers with a &lt;code&gt;result&lt;/code&gt; or an &lt;code&gt;error&lt;/code&gt;. REST makes you decide on routes, HTTP verbs and status codes; JSON-RPC only cares about which method to call and with what parameters. For an API where you own both the front end and the back end, that is pleasant to work with.&lt;/p&gt;
&lt;p&gt;Polhem.JsonRpc is a set of .NET packages for JSON-RPC 2.0: a transport-independent server, an ASP.NET Core endpoint and a client, built on System.Text.Json alone. It started as the API layer of the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem&quot;&gt;Polhem framework&lt;/a&gt;. I have now extracted it into standalone packages that do not depend on the framework, so other projects can reference them directly. The source is on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc&quot;&gt;GitHub&lt;/a&gt; under the MIT license, and the packages are on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages?q=Polhem.JsonRpc&quot;&gt;NuGet&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-what-it-covers&quot;&gt;1️⃣ What it covers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Full JSON-RPC 2.0: ordinary calls, notifications (calls that expect no response), and batches (several calls in one message). An id can be a string, an integer or &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A transport-independent server: the dispatcher (the part that finds and runs the method) knows nothing about HTTP. The ASP.NET Core endpoint is a separate package.&lt;/li&gt;
&lt;li&gt;A client: an HTTP transport, batches, notifications, and interceptors that can rewrite the parameters and result of each call.&lt;/li&gt;
&lt;li&gt;Optional payload encryption: the parameters and result go into an envelope that can be compressed and encrypted, and replayed calls can be rejected.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is split into several NuGet packages, so an application references only what it needs:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Package &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc&quot;&gt;Polhem.JsonRpc&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Message types, error codes and the transport abstraction shared by the server and the client&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Server&quot;&gt;Polhem.JsonRpc.Server&lt;/a&gt; &lt;/td&gt;&lt;td&gt;The dispatcher: method resolution, parameter binding and filters&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.AspNetCore&quot;&gt;Polhem.JsonRpc.AspNetCore&lt;/a&gt; &lt;/td&gt;&lt;td&gt;The ASP.NET Core endpoint, &lt;code&gt;MapJsonRpc&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Client&quot;&gt;Polhem.JsonRpc.Client&lt;/a&gt; &lt;/td&gt;&lt;td&gt;The client, &lt;code&gt;JsonRpcConnector&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Payload&quot;&gt;Polhem.JsonRpc.Payload&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Optional: the payload envelope, compression, encryption and replay protection&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Client&quot;&gt;Polhem.JsonRpc.Payload.Client&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Optional: a client that seals the parameters and opens the result of every call&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/packages/Polhem.JsonRpc.Payload.Server&quot;&gt;Polhem.JsonRpc.Payload.Server&lt;/a&gt; &lt;/td&gt;&lt;td&gt;Optional: opens the envelope and checks for replays on the server&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The server references &lt;code&gt;Polhem.JsonRpc.AspNetCore&lt;/code&gt;, and the client references only &lt;code&gt;Polhem.JsonRpc.Client&lt;/code&gt;. The split keeps a client, whether a desktop app or a mobile app, from pulling in ASP.NET Core and the reflection-based dispatcher.&lt;/p&gt;
&lt;p&gt;Native AOT compiles an app to native code at publish time, so nothing can rely on reflection at run time; iOS and WebAssembly both need it. The shared package, the client and the payload packages are marked AOT-compatible, and CI publishes a test program with Native AOT and runs it. The server package finds methods by reflection, so it does not support AOT. The target framework is &lt;code&gt;net10.0&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-getting-started&quot;&gt;2️⃣ Getting started&lt;/h3&gt;
&lt;h4 id=&quot;asp-net-core-server-minimal-api-&quot;&gt;ASP.NET Core server (Minimal API)&lt;/h4&gt;
&lt;p&gt;The server uses ASP.NET Core Minimal APIs (endpoints mapped directly in &lt;code&gt;Program.cs&lt;/code&gt; with &lt;code&gt;MapXxx&lt;/code&gt;, without MVC controllers). &lt;code&gt;MapJsonRpc&lt;/code&gt; creates a POST endpoint at the given path, and every JSON-RPC call comes in through it. &lt;code&gt;AddControllers&lt;/code&gt; is not needed.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Program.cs&lt;/code&gt; needs three things: register the object factory, register the JSON-RPC services, and map the endpoint.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using Polhem.JsonRpc.AspNetCore;
using Polhem.JsonRpc.Server;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton&amp;lt;IJsonRpcObjectFactory, AppObjectFactory&amp;gt;();
builder.Services.AddJsonRpcServer();

var app = builder.Build();
app.MapJsonRpc(&quot;/api&quot;);
await app.RunAsync();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AddJsonRpcServer&lt;/code&gt; takes two sets of options. The first configures the server itself, such as filters, the method policy, the batch limit and serializer options. The second configures the HTTP endpoint, such as the request body size limit and which HTTP status code an error returns. Leave them out and the defaults apply.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;builder.Services.AddJsonRpcServer(
    options =&amp;gt;
    {
        options.MaxBatchSize = 50;
        options.Filters.Add(new ApiKeyFilter(apiKey)); // see section 5
    },
    http =&amp;gt; http.MaxRequestBodySize = 1024 * 1024);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MapJsonRpc&lt;/code&gt; returns the same thing as any other Minimal API endpoint, so authorization, CORS and rate limiting chain on the usual way, for example &lt;code&gt;app.MapJsonRpc(&quot;/api&quot;).RequireAuthorization();&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The object factory and the class that handles the calls:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public sealed class AppObjectFactory : IJsonRpcObjectFactory
{
    public object? CreateObject(string progId, JsonRpcRequestContext context) =&amp;gt; progId switch
    {
        &quot;Calculator&quot; =&amp;gt; new Calculator(),
        _ =&amp;gt; null,
    };
}

public sealed class Calculator
{
    public AddResponse Add(AddRequest request) =&amp;gt; new() { Sum = request.A + request.B };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;client&quot;&gt;Client&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;using var http = new HttpClient { BaseAddress = new Uri(&quot;http://localhost:5080/api&quot;) };
var rpc = new JsonRpcConnector(new HttpTransport(http));

var added = await rpc.InvokeAsync&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, new AddRequest { A = 1, B = 2 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is enough to call &lt;code&gt;Calculator.Add&lt;/code&gt;. Note that the server has no &lt;code&gt;AddMethod&lt;/code&gt; or similar registration, and &lt;code&gt;Calculator&lt;/code&gt; carries no attribute.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-with-no-registration-which-methods-can-be-called-&quot;&gt;3️⃣ With no registration, which methods can be called?&lt;/h3&gt;
&lt;p&gt;Skipping registration is convenient, but it raises a worry: if one day I add a public method to the class for internal use, can it be called from the network too? When the server receives &lt;code&gt;Calculator.Add&lt;/code&gt;, it runs the following checks in order.&lt;/p&gt;
&lt;p&gt;Name format. The method name must be &lt;code&gt;ProgId.Action&lt;/code&gt;. A ProgId (program identifier) may contain letters, digits, underscores and hyphens; an action letters, digits and underscores; each at most 64 characters. A malformed name is rejected before any lookup.&lt;/p&gt;
&lt;p&gt;The application decides the object. The ProgId is passed to your implementation of &lt;code&gt;IJsonRpcObjectFactory.CreateObject&lt;/code&gt;, which decides which object to create; returning &lt;code&gt;null&lt;/code&gt; means the ProgId is unknown. The packages keep no registry of names, so which ProgIds exist is entirely up to the factory. After the call, the object is passed to &lt;code&gt;ReleaseObjectAsync&lt;/code&gt; to be released.&lt;/p&gt;
&lt;p&gt;Method signature. An action maps only to a public, non-generic instance method with exactly one parameter. Static methods, property accessors and methods declared by &lt;code&gt;object&lt;/code&gt; do not count. If two or more methods with the same name qualify, the server does not pick one; it treats the method as not found.&lt;/p&gt;
&lt;p&gt;Naming convention. The parameter type must be named &lt;code&gt;{Action}Request&lt;/code&gt;, and the return type (or the result type of &lt;code&gt;Task&amp;lt;T&amp;gt;&lt;/code&gt; or &lt;code&gt;ValueTask&amp;lt;T&amp;gt;&lt;/code&gt;) must be named &lt;code&gt;{Action}Response&lt;/code&gt;. &lt;code&gt;Add&lt;/code&gt; takes an &lt;code&gt;AddRequest&lt;/code&gt; and returns an &lt;code&gt;AddResponse&lt;/code&gt;. This is the default method policy; any public method that does not follow it is treated as nonexistent and answered with &lt;code&gt;-32601 Method not found&lt;/code&gt;. An internal helper method that does not follow this naming cannot be called from outside.&lt;/p&gt;
&lt;p&gt;The rule can be replaced through &lt;code&gt;JsonRpcServerOptions.MethodPolicy&lt;/code&gt;. The Polhem framework replaces it with one that reads its own access-control attribute.&lt;/p&gt;
&lt;p&gt;Policy before filters. The method policy runs before every filter, so filters such as authorization checks or decryption only ever see calls that are allowed to run.&lt;/p&gt;
&lt;p&gt;Only after these checks are the &lt;code&gt;params&lt;/code&gt; bound. &lt;code&gt;params&lt;/code&gt; must be a JSON object, deserialized into the request class with camelCase names by default; missing &lt;code&gt;params&lt;/code&gt; or an array (positional parameters) are answered with &lt;code&gt;-32602 Invalid params&lt;/code&gt;. To return a custom error, a method throws &lt;code&gt;JsonRpcErrorException&lt;/code&gt;; any other exception is answered with &lt;code&gt;-32603 Internal error&lt;/code&gt;, and the exception message is not sent to the client.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-the-client&quot;&gt;4️⃣ The client&lt;/h3&gt;
&lt;p&gt;An error returned by the server becomes a &lt;code&gt;JsonRpcErrorException&lt;/code&gt;, which carries the error code and message:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;try
{
    await rpc.InvokeAsync&amp;lt;DivideResponse&amp;gt;(&quot;Calculator.Divide&quot;, new DivideRequest { Dividend = 1, Divisor = 0 });
}
catch (JsonRpcErrorException ex)
{
    Console.WriteLine($&quot;Error {ex.Code}: {ex.Message}&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notifications and batches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// The server runs it but does not respond.
await rpc.NotifyAsync(&quot;Calculator.Log&quot;, new LogRequest { Message = &quot;Hello&quot; });

// Two calls sent in the same message.
var batch = rpc.CreateBatch();
var first = batch.Add&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, new AddRequest { A = 2, B = 3 });
var second = batch.Add&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, new AddRequest { A = 4, B = 5 });
await batch.SendAsync();
Console.WriteLine($&quot;{(await first)!.Sum}, {(await second)!.Sum}&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The request and response classes usually live in a project shared by both sides, which is how the samples in the repo are arranged.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-extension-points-filters-and-interceptors&quot;&gt;5️⃣ Extension points: filters and interceptors&lt;/h3&gt;
&lt;p&gt;A server filter wraps every call. It can reject the call, rewrite the parameters before the method runs, and rewrite the result afterwards. For example, checking an API key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;public sealed class ApiKeyFilter(string expectedKey) : IJsonRpcFilter
{
    public ValueTask InvokeAsync(JsonRpcRequestContext context, JsonRpcFilterDelegate next)
    {
        context.Transport.Headers.TryGetValue(&quot;X-Api-Key&quot;, out var key);
        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.UTF8.GetBytes(key ?? &quot;&quot;), Encoding.UTF8.GetBytes(expectedKey)))
        {
            throw new JsonRpcErrorException(-32001, &quot;Unauthorized&quot;);
        }
        return next(context);
    }
}

builder.Services.AddJsonRpcServer(options =&amp;gt; options.Filters.Add(new ApiKeyFilter(apiKey)));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To add an HTTP header on the client, use &lt;code&gt;HttpClient&lt;/code&gt;'s own &lt;code&gt;DelegatingHandler&lt;/code&gt;; the packages add no layer of their own. To rewrite the parameters and result of every call, add an &lt;code&gt;IJsonRpcClientInterceptor&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The Polhem framework plugs its sessions and authorization checks into these extension points (the object factory, the method policy and filters). None of that logic is in the packages themselves.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-encrypted-payloads&quot;&gt;6️⃣ Encrypted payloads&lt;/h3&gt;
&lt;p&gt;HTTPS protects the connection. If the parameters and results also need end-to-end protection, or a call must not run a second time when it is resent, add the optional payload packages.&lt;/p&gt;
&lt;p&gt;They put &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;result&lt;/code&gt; into an envelope with three formats: plain JSON, encoded (serialized, then gzip-compressed), and encrypted (AES-256-CBC with HMAC-SHA256, encrypt-then-MAC). A frame carrying a timestamp and a sequence number can also be put in front of the content, so the server can reject expired or repeated requests; that is the replay protection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-csharp&quot;&gt;// Server: the application's policy decides the keys and which methods must be encrypted or replay-protected.
builder.Services.AddJsonRpcServer(options =&amp;gt; options.UsePayload(payloadOptions, new MyPayloadPolicy()));

// Client: called the same way as an ordinary call; each call seals its parameters and opens its result.
var rpc = new PayloadConnector(connector, new PayloadProcessor(payloadOptions),
    new PayloadConnectorOptions { KeyProvider = () =&amp;gt; sessionKey });
var added = await rpc.InvokeAsync&amp;lt;AddResponse&amp;gt;(&quot;Calculator.Add&quot;, request);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few more security details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The HMAC is compared in constant time and verified before anything is decrypted.&lt;/li&gt;
&lt;li&gt;The HMAC covers the method name and the direction, so an intercepted call cannot be redirected to another method, and a response cannot be sent back as a request.&lt;/li&gt;
&lt;li&gt;A method can require a minimum format; a request in a lower format is rejected before any key is asked for.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How keys are negotiated (exchanged after sign-in, provisioned in advance, and so on) is outside the packages and left to the application.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-how-it-differs-from-common-libraries&quot;&gt;7️⃣ How it differs from common libraries&lt;/h3&gt;
&lt;p&gt;There are quite a few JSON-RPC libraries for .NET; here are two common ones for comparison. The version in each column header is the latest stable release on NuGet as of October 2026, and the details come from that release's documentation and NuGet dependency list. Later releases may differ.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;  &lt;/th&gt;&lt;th&gt;StreamJsonRpc 2.25.29 &lt;/th&gt;&lt;th&gt;Tochka.JsonRpc 7.4.1 &lt;/th&gt;&lt;th&gt;Polhem.JsonRpc 1.2.0&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;JSON library &lt;/td&gt;&lt;td&gt;Newtonsoft.Json by default, switchable to System.Text.Json; the package still depends on Newtonsoft.Json and MessagePack &lt;/td&gt;&lt;td&gt;System.Text.Json &lt;/td&gt;&lt;td&gt;System.Text.Json, with no other dependencies&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Transport model &lt;/td&gt;&lt;td&gt;Full duplex over a Stream, WebSocket or Pipe &lt;/td&gt;&lt;td&gt;HTTP on ASP.NET Core MVC &lt;/td&gt;&lt;td&gt;HTTP request and response on ASP.NET Core Minimal APIs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Client &lt;/td&gt;&lt;td&gt;Yes, with proxies generated from interfaces &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How methods are registered &lt;/td&gt;&lt;td&gt;&lt;code&gt;AddLocalRpcTarget&lt;/code&gt; adds a target object whose public methods become callable &lt;/td&gt;&lt;td&gt;Controllers that inherit &lt;code&gt;JsonRpcControllerBase&lt;/code&gt;; each action is a method &lt;/td&gt;&lt;td&gt;No registration; found by &lt;code&gt;ProgId.Action&lt;/code&gt; and the naming convention&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Native AOT &lt;/td&gt;&lt;td&gt;Partially supported, within the limits its documentation lists &lt;/td&gt;&lt;td&gt;Tied to MVC, not supported &lt;/td&gt;&lt;td&gt;The shared package, the client and the payload packages support it; the server does not&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Good fit &lt;/td&gt;&lt;td&gt;Two-way communication, such as Visual Studio extensions or LSP &lt;/td&gt;&lt;td&gt;You already use MVC and want to keep writing controllers &lt;/td&gt;&lt;td&gt;An API where you own both ends, possibly with desktop or mobile clients&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;StreamJsonRpc is the most complete, with cancellation, progress reporting and server-to-client calls, but it is designed for streams and full duplex; turning it into an HTTP request/response API means building the endpoint yourself.&lt;/p&gt;
&lt;p&gt;Tochka.JsonRpc is the closest in scope to Polhem.JsonRpc. It builds on MVC controllers, so MVC filters and model binding work as they are, at the cost of sharing MVC's lack of AOT support.&lt;/p&gt;
&lt;p&gt;Polhem.JsonRpc goes in a different direction: it trades a fixed call shape for &quot;no registration&quot; and &quot;an API that can evolve&quot;, as the next section explains.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-design-trade-offs&quot;&gt;8️⃣ Design trade-offs&lt;/h3&gt;
&lt;p&gt;The fixed call shape is there so the API can evolve. The &lt;code&gt;ProgId.Action&lt;/code&gt; name, one request class and one response class are a deliberate restriction. When a member is added to a request or response class, a member an older front end does not send binds to its default on the server, and a result member an older front end does not know is simply skipped when read. Old and new front ends call the same method, with no &lt;code&gt;v1&lt;/code&gt; and &lt;code&gt;v2&lt;/code&gt; endpoints to maintain. Positional parameters cannot do this: add, remove or reorder one parameter and every caller has to change.&lt;/p&gt;
&lt;p&gt;Defaults follow the specification. The internal error code is &lt;code&gt;-32603&lt;/code&gt;, a response carries only &lt;code&gt;jsonrpc&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt; is always written, as &lt;code&gt;null&lt;/code&gt; when it cannot be determined. The Polhem framework's original response format differed from the specification in places; when the packages were extracted, it was the framework that changed to follow the specification, not the packages that bent to the old format.&lt;/p&gt;
&lt;p&gt;The reasoning behind each decision is recorded as ADRs (Architecture Decision Records) in the repo's &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc/tree/main/maintainers/adr&quot;&gt;&lt;code&gt;maintainers/adr/&lt;/code&gt;&lt;/a&gt;, and the security design is summarized in &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc/blob/main/docs/security.md&quot;&gt;&lt;code&gt;docs/security.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-wrapping-up&quot;&gt;✅ Wrapping up&lt;/h3&gt;
&lt;p&gt;Install the server and client packages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;dotnet add package Polhem.JsonRpc.AspNetCore
dotnet add package Polhem.JsonRpc.Client
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The repo includes &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/polhem-dev/polhem-jsonrpc/tree/main/samples&quot;&gt;samples&lt;/a&gt;: an ASP.NET Core Minimal API server, a console client (ordinary calls, error handling, notifications and batches), and a server and client using encrypted payloads.&lt;/p&gt;
&lt;p&gt;These packages do convention-based JSON-RPC, not general-purpose JSON-RPC. Protocols whose method names and parameters are defined by someone else, such as the Language Server Protocol, MCP or an Ethereum node's API (names like &lt;code&gt;textDocument/didOpen&lt;/code&gt; and &lt;code&gt;eth_call&lt;/code&gt; with positional parameters), cannot be implemented with them; StreamJsonRpc or the MCP C# SDK fit those better. Nor do they offer a full-duplex connection where the server calls the client: one call is one HTTP request and its response.&lt;/p&gt;
&lt;p&gt;If you own both ends of your API, want JSON-RPC that relies on System.Text.Json alone, or have clients that run as desktop or mobile apps, give it a try. Feedback, issues and pull requests are welcome.&lt;/p&gt;
&lt;h3 id=&quot;further-reading&quot;&gt;Further reading&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.jsonrpc.org/specification&quot;&gt;JSON-RPC 2.0 Specification&lt;/a&gt;: the full JSON-RPC 2.0 specification&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/microsoft/vs-streamjsonrpc&quot;&gt;StreamJsonRpc&lt;/a&gt;: the choice when you need full-duplex streams&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tochka-public/Tochka.JsonRpc&quot;&gt;Tochka.JsonRpc&lt;/a&gt;: JSON-RPC built on ASP.NET Core MVC controllers&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://learn.microsoft.com/dotnet/core/deploying/native-aot/&quot;&gt;Native AOT deployment&lt;/a&gt;: the limits and compatibility of .NET Native AOT&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;📘 Original article on HackMD, in Traditional Chinese:&lt;br&gt;
👉 &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377/polhem-jsonrpc&quot;&gt;https://hackmd.io/@jeff377/polhem-jsonrpc&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;📢 You are welcome to republish this, please credit the source.&lt;br&gt;
📬 Follow for more engineering notes:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.facebook.com/profile.php?id=61574839666569&quot;&gt;Facebook&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackmd.io/@jeff377&quot;&gt;HackMD&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/jeff377&quot;&gt;GitHub&lt;/a&gt; ｜ &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.nuget.org/profiles/jeff377&quot;&gt;NuGet&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32964/a-json-rpc-library-with-no-method-registration-how-does-it-decide-what-can-be-called</guid>
<pubDate>Thu, 29 Oct 2026 16:15:05 +0000</pubDate>
</item>
<item>
<title>Project the Response, Not the Cache</title>
<link>https://coderlegion.com/29355/project-the-response-not-the-cache</link>
<description>&lt;p&gt;A response can be correct for one caller and still leave the application in a worse state for everyone who follows.&lt;/p&gt;
&lt;p&gt;This is an easy trap in ASP.NET Core when a result filter, formatter, or middleware needs to change a few fields before a response leaves the server. Localization is a useful example: a DTO contains source-language text, the current request asks for another language, and the pipeline replaces selected strings before serialization.&lt;/p&gt;
&lt;p&gt;The first response looks right. The implementation may even be pleasantly small.&lt;/p&gt;
&lt;p&gt;The danger is ownership. If the returned DTO came from an in-process cache, the response pipeline may be holding the same object instance that another request, background job, or command path will read later. What looked like response formatting was actually a write to shared state.&lt;/p&gt;
&lt;p&gt;The broader lesson is this: request-specific presentation should be a projection, not a mutation.&lt;/p&gt;
&lt;h3 id=&quot;the-bug-hides-behind-a-successful-response&quot;&gt;The Bug Hides Behind a Successful Response&lt;/h3&gt;
&lt;p&gt;Imagine a cached catalogue item with a description in the system’s source language. A request asks for a translated version. The tempting implementation walks the object graph and assigns the translated string to the DTO property.&lt;/p&gt;
&lt;p&gt;The response is translated correctly. A test that checks only that response passes.&lt;/p&gt;
&lt;p&gt;Now consider the next reader. If the cache returns the same instance, the cached item is no longer in its authoritative source form. A source-language request may receive translated text. A command that maps the DTO back into another model may persist presentation text as source data. A background process may observe a value chosen for somebody else’s request.&lt;/p&gt;
&lt;p&gt;Nothing about the first HTTP response reveals this. The defect is temporal: the observable failure belongs to a later reader.&lt;/p&gt;
&lt;p&gt;That is why “the output is correct” is not a sufficient assertion when inputs can be shared.&lt;/p&gt;
&lt;h3 id=&quot;draw-the-ownership-boundary-explicitly&quot;&gt;Draw the Ownership Boundary Explicitly&lt;/h3&gt;
&lt;p&gt;A useful model is to separate three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The cached or domain DTO is authoritative input.&lt;/li&gt;
&lt;li&gt;Request context selects presentation rules.&lt;/li&gt;
&lt;li&gt;The HTTP payload is response-owned output.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once those roles are explicit, several safe implementations become available. You can map into a dedicated response model. You can clone the graph and change the clone. Or you can project replacement values at the serialization boundary.&lt;/p&gt;
&lt;p&gt;In the last approach, the traversal discovers which string properties need different values but never assigns those values to the source objects. The serializer then substitutes the replacements while creating a JSON tree owned by the response. Nested objects, collections, readonly wrappers, and init-only properties keep their wire shape because the original serializer contract still defines the payload.&lt;/p&gt;
&lt;p&gt;The important property is not the particular API used. It is that no request-specific operation writes into caller-owned or cache-owned objects.&lt;/p&gt;
&lt;p&gt;This idea also applies to redaction, unit formatting, enrichment, API-version shaping, and field masking. If the transformation varies by caller or request, it probably belongs in an output projection.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-common-case-fast-path&quot;&gt;Preserve the Common-Case Fast Path&lt;/h3&gt;
&lt;p&gt;Projection has a cost. It may require an additional JSON materialization, replacement lookup, and short-lived allocations. That cost should be acknowledged rather than hidden behind a correctness slogan.&lt;/p&gt;
&lt;p&gt;The practical response is to make the cheap path obvious. When the request already uses the source representation, pass the original result through untouched. Only requests that need transformation pay for projection.&lt;/p&gt;
&lt;p&gt;Failure behavior also matters. If the projection cannot handle an unusual graph, the pipeline should not leave a half-mutated object behind. A safe fallback retains the original result and logs the failure according to the application’s policy. Whether returning the untransformed payload is acceptable depends on the feature and data classification; sensitive redaction may need to fail closed instead.&lt;/p&gt;
&lt;p&gt;Security-sensitive payloads and error results may also deserve explicit exclusion. Presentation machinery should not accidentally inspect tokens, secrets, or payload shapes that were never part of its contract.&lt;/p&gt;
&lt;h3 id=&quot;test-the-second-reader&quot;&gt;Test the Second Reader&lt;/h3&gt;
&lt;p&gt;The highest-value test is a sequence, not a snapshot:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Put a source-language object into the real cache implementation used by the host.&lt;/li&gt;
&lt;li&gt;Request a transformed response and verify its output.&lt;/li&gt;
&lt;li&gt;Read the cached object again and verify that it is unchanged.&lt;/li&gt;
&lt;li&gt;Issue a source-language request and verify that it still receives the source value.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Run that sequence through the real result filter and serializer options, not only a mocked translation service. The ownership bug lives in the composition between cache identity, result handling, graph traversal, and serialization.&lt;/p&gt;
&lt;p&gt;Add focused cases for ordinary object results and explicit JSON results, nested collections, readonly response wrappers, init-only properties, error responses, exempt payloads, and projection failure. These tests establish both the intended transformation and the things it must never change.&lt;/p&gt;
&lt;p&gt;A mutation-resistance assertion is especially valuable: keep a reference to the original object and prove its properties retain their exact values after the response executes.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-isolation-versus-local-simplicity&quot;&gt;The Trade-Off Is Isolation Versus Local Simplicity&lt;/h3&gt;
&lt;p&gt;In-place mutation is easy to understand in one method and can avoid an extra materialization. Its cost appears elsewhere: temporal coupling, language or user context leaking between requests, and tests that pass individually while failing in sequence.&lt;/p&gt;
&lt;p&gt;Response projection adds code, allocations, and serializer-specific reasoning. In return, it makes ownership visible. The cache remains authoritative, requests remain isolated, and failures cannot leave shared objects partially transformed.&lt;/p&gt;
&lt;p&gt;That is a trade I will usually take. I would still measure the transformed path with representative payload sizes and keep the source path cheap. But performance work should begin after the ownership contract is correct.&lt;/p&gt;
&lt;p&gt;The practical rule is short: if a value is shared, treat it as immutable. If presentation depends on the request, produce response-owned output. And when you test it, always ask what the next reader sees.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29355/project-the-response-not-the-cache</guid>
<pubDate>Thu, 01 Oct 2026 05:50:20 +0000</pubDate>
</item>
<item>
<title>Treat Every ID as an Authorization Claim</title>
<link>https://coderlegion.com/29225/treat-every-id-as-an-authorization-claim</link>
<description>&lt;p&gt;Authentication answers one important question: who made this request? It does not answer a second question that often hides inside an ordinary-looking query parameter: may this caller name the record represented by this identifier?&lt;/p&gt;
&lt;p&gt;That gap is easy to miss in APIs that accept a parent ID, child ID, account ID, project ID, or tenant ID. The route is authenticated. The identifier has the correct shape. The query returns a valid result. Yet none of those facts proves that the record belongs inside the caller's authorized scope.&lt;/p&gt;
&lt;p&gt;A useful engineering rule is to treat every caller-supplied identifier as an authorization claim before treating it as a query input.&lt;/p&gt;
&lt;h3 id=&quot;authentication-is-not-object-ownership&quot;&gt;Authentication Is Not Object Ownership&lt;/h3&gt;
&lt;p&gt;Imagine an endpoint that returns activities for a supplied household record. The controller requires a signed-in user, then passes the supplied ID into a query service. If the ID is changed in transit, the query may still succeed—just for somebody else's data.&lt;/p&gt;
&lt;p&gt;The vulnerable assumption is subtle: “signed in” becomes “allowed to ask about any existing record.” The database faithfully answers the question it receives. The missing control belongs before the query.&lt;/p&gt;
&lt;p&gt;The server should derive the caller's authority from trusted identity and relationship data. For an ordinary user, that might mean resolving the account to its authoritative person record, then traversing explicit relationships to the permitted records. For an operational role, a specific permission may allow a wider scope because that role already performs work requiring the same visibility.&lt;/p&gt;
&lt;p&gt;This also explains why email equality is a poor ownership rule. Addresses are corrected, reused, shared by families, copied into contact fields, and sometimes retained after account changes. An email can help locate a candidate during migration, but it should not silently replace an authoritative account-to-person relationship.&lt;/p&gt;
&lt;h3 id=&quot;put-the-boundary-before-the-query&quot;&gt;Put the Boundary Before the Query&lt;/h3&gt;
&lt;p&gt;A safe request pipeline makes the order explicit:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Identify the caller from trusted authentication state.&lt;/li&gt;
&lt;li&gt;Resolve the caller's authoritative records and relationships on the server.&lt;/li&gt;
&lt;li&gt;Evaluate each supplied identifier against that scope.&lt;/li&gt;
&lt;li&gt;Only then construct and execute the data query.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This order prevents a common failure mode: retrieving a broad result and hoping a later projection removes everything sensitive. Late filtering spreads the security contract across repositories, mappers, and UI code. Early scope resolution gives the query a smaller, already-authorized input.&lt;/p&gt;
&lt;p&gt;Do not bundle independent identifiers into one vague decision. If a request contains both a parent filter and a child filter, check each one. Mixed requests are exactly where “one valid field makes the request valid” bugs appear. A valid parent must not bless an unrelated child; an unrelated parent must not erase a valid child's boundary.&lt;/p&gt;
&lt;h3 id=&quot;reject-by-default-narrow-only-deliberately&quot;&gt;Reject by Default, Narrow Only Deliberately&lt;/h3&gt;
&lt;p&gt;For most data-specific routes, a cross-scope identifier should be rejected. Rejection is direct, observable, and easier to reason about. It tells the client that its request crossed a boundary and gives operators a signal worth investigating.&lt;/p&gt;
&lt;p&gt;Legacy mobile clients complicate that clean answer. An installed client may send an old identifier shape, interpret a forbidden response as a session failure, or sign the user out. Waiting for every old version to disappear can leave a real data boundary open longer than necessary.&lt;/p&gt;
&lt;p&gt;There is a narrow alternative: compatibility-preserving narrowing. The server validates every supplied identifier, discards any that are not authorized, and executes only a known-safe baseline query. The key invariant is monotonicity: removing an unauthorized filter must never widen the response into the requested protected data.&lt;/p&gt;
&lt;p&gt;For example, a calendar route might combine household-specific entries with public entries. If an old client sends an account ID where a domain record ID is expected, the server can remove that invalid household filter and return only the public baseline. It must not substitute another household, guess from a weak attribute, or run an unscoped “all entries” query.&lt;/p&gt;
&lt;p&gt;This is not a general recommendation to hide authorization failures. It is an endpoint-specific migration tool. Document why it exists, record how it is used, and give it a removal condition.&lt;/p&gt;
&lt;h3 id=&quot;test-the-boundary-not-just-the-happy-path&quot;&gt;Test the Boundary, Not Just the Happy Path&lt;/h3&gt;
&lt;p&gt;Authorization tests should form a matrix around relationships and permissions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the caller names their own authoritative record;&lt;/li&gt;
&lt;li&gt;the caller names a related child record;&lt;/li&gt;
&lt;li&gt;the caller names another household's record;&lt;/li&gt;
&lt;li&gt;the caller has no corresponding relationship;&lt;/li&gt;
&lt;li&gt;a shared or copied email appears on a record the caller does not own;&lt;/li&gt;
&lt;li&gt;a staff role lacks the exact cross-scope permission;&lt;/li&gt;
&lt;li&gt;a staff role holds that permission;&lt;/li&gt;
&lt;li&gt;one filter is authorized while another is not;&lt;/li&gt;
&lt;li&gt;the legacy request shape still receives the same safe baseline.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last two cases are especially valuable. Mixed filters catch accidental “any match” logic. Legacy-shape tests prevent a future cleanup from turning a deliberate compatibility response back into either a leak or a destructive client error.&lt;/p&gt;
&lt;p&gt;Tests should assert returned data as well as status codes. A successful response is safe only if its contents stay inside the proven scope. A forbidden response is useful only if the client contract can tolerate it.&lt;/p&gt;
&lt;h3 id=&quot;make-the-exception-expensive-to-forget&quot;&gt;Make the Exception Expensive to Forget&lt;/h3&gt;
&lt;p&gt;Compatibility code tends to become permanent when its rationale lives only in memory. Keep the exception close to the boundary, name the safe baseline, add telemetry that contains no sensitive identifiers, and define the client version or date after which strict rejection can replace narrowing.&lt;/p&gt;
&lt;p&gt;The trade-off is real. Strict rejection gives a simpler model and clearer misuse signals. Narrowing adds policy, tests, and the possibility of masking a broken client. Its value is speed: the server can close the data boundary now while preserving a safe experience for older installations.&lt;/p&gt;
&lt;p&gt;The practical standard is modest but powerful: never let a caller-provided ID reach a query merely because it parses. First prove that the caller may name it. If compatibility forces an exception, make the request narrower, make the safe response explicit, and make the exception temporary.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29225/treat-every-id-as-an-authorization-claim</guid>
<pubDate>Wed, 30 Sep 2026 10:38:55 +0000</pubDate>
</item>
</channel>
</rss>