The previous post, JSON-RPC 2.0: Protocol Choice and the Request Pipeline, was about how a call is handled once it reaches the server. The same contract needs somebody responsible on the client side too: some clients connect to a server on the other side of a network, and some run in the same process as the back end logic, yet both are calling the same method.
If everything in between that has nothing to do with the business is left to each front end, there will be as many ways of writing it as there are front ends, and the same mistake will happen once in every one of them. The framework's answer is a Connector layer on the client side as well, which every front end goes through.
This post covers:
- Which things the client has to handle, and why they are collected into one type
- How a local and a remote connection become the same call, and where the real difference lies
- Which state belongs to one logged in user, and which belongs to the whole application
1. The client's responsibilities, not just sending
The layers one save passes through, from the screen to the database, are fixed:
Client
screen
↓
DataObject: the framework's ViewModel, wrapping a DataSet for the screen to bind to
↓
Connector (this post)
↓ one JSON-RPC call
Server
API dispatch: method cut into ProgId and action
↓
business object: the one the ProgId exchanged for in the type registry
↓
repository: statements assembled from FormSchema
↓
database
The Connector layer on that diagram is the client side role handling a JSON-RPC call: how a call is assembled into a request, how it is sent, and how the response is restored are all its job, and the front end does not have to know whether the other end is across a network or in the same process.
All the screen layer wants to say is "save this form". For that sentence to actually hold, a string of things unrelated to this particular form sit in between, and every one of them has to be done right:
| This task | Collected, it is done by | Not collected, it is done by |
|---|---|---|
| Addressing | Joining the ProgId and the action into a method |
Every call site assembling that string itself |
| Identity | Putting the token and the API key where they belong | Every call assembling headers itself |
| Transport | Picking a route by mode and sending and receiving on it | Every front end wiring that up once |
| Encoding | Deciding whether this call is encoded and encrypted | Every front end implementing that once |
| Time zone | Converting to UTC on the way out and back to the user's zone on the way in | Every screen converting it, and having to remember to |
| Return values | Turning what is inside the API payload into the type the caller declared | Deserializing it by hand |
| Errors | Turning the error field back into an exception | Every call site judging error codes itself |
All seven sit on one path, ExecuteAsync on ApiConnector. With tracing and exception wrapping taken out, its skeleton is this:
protected async Task<T> ExecuteAsync<T>(string progId, string action, object value, PayloadFormat format)
{
var timeZoneId = UserTimeZoneId;
T result;
using (PayloadZoneConverter.ToUtc(value, timeZoneId))
{
var (request, actualFormat) = PrepareRequest(progId, action, value, format);
var response = await this.Provider.ExecuteAsync(request);
result = FinalizeResponse<T>(response, actualFormat);
}
PayloadZoneConverter.ToUserZone(result, timeZoneId);
return result;
}
In the request PrepareRequest assembles, Method is $"{progId}.{action}", the two segment method from the previous post, and the stretch that cuts it apart is on the server. The JSON-RPC payload at both ends is assembled by the same code, so the framework's own client never meets any of those four distances from the specification. FinalizeResponse is where the error field is turned back into an exception, which is why no front end has to recognize error codes for itself, and what each of those codes means is the next post's subject.
Those seven are not just a list, their order is fixed. Time zones have to be converted before encoding, because after encoding the params slot already holds bytes, and on the way back decoding has to happen before conversion into a type.
One pair among them is deliberately placed: errors are read before decoding, because the error field hangs on the JSON-RPC payload itself rather than inside the encoded slot, so a failed call does not have to decrypt anything to learn that it failed. The server pipeline has one deliberately placed order like this too, and this one on the client is far shorter for the same reason.
The acceptance line from the previous post has another side here: failures before acceptance come back as an HTTP status code and failures after it travel in the error field, and the client's sending statement checks the status code, so the two kinds of failure are two different catches at this end.
Three subclasses, three ways of obtaining a ProgId
The base class handles only the path above, and where the ProgId comes from is left to the subclass.
| Type | Where the ProgId comes from |
Which calls it handles |
|---|---|---|
ApiConnector |
Taken as a call argument | The abstract base, not used directly |
SystemApiConnector |
Fixed to System
|
System level calls: login, entering and leaving a company, and fetching definitions |
LogApiConnector |
Fixed to AuditLog
|
Audit trail queries |
FormApiConnector |
Taken through its constructor | Form level calls, meaning any form at all |
Both fixed values are reserved words in the type registry from API Contract Layering and the Type Registry. A reserved word is a registry key on the server and a constructor needing no arguments on the client, two sides of one decision.
The six form level actions each have a matching method on FormApiConnector: GetListAsync, GetLookupAsync, GetNewDataAsync, GetDataAsync, SaveAsync and DeleteAsync. The previous post said eight forms and a thousand forms are the same six actions, and this is that fact's shape on the client: a thousand forms share one type, and the only difference is the string given at construction.
2. The same call, local and remote
An ERP system's clients do not come in one deployment shape, and desktop, browser and the server's own background services all count. What sorts them into two kinds is what sits between them and the back end logic:
- Remote: a front end in the user's hands makes a call, crosses a network to the server, and the server walks on to the business object
- Local: a background service taking the same contract has no network in between, and reaches the business object inside the same process
The framework's handling is to abstract "how it is sent" into an interface with exactly one method, Task<JsonRpcResponse> ExecuteAsync(JsonRpcRequest request), and two implementations. The remote one, RemoteApiProvider, turns the request into JSON, attaches two headers, POSTs it, and restores what comes back:
var headers = new NameValueCollection
{
{ ApiHeaders.ApiKey, ApiClientInfo.ApiKey },
{ ApiHeaders.Authorization, $"Bearer {AccessToken}" }
};
string json = await HttpUtilities.PostAsync(Endpoint, request.ToJson(), headers);
return JsonCodec.Deserialize<JsonRpcResponse>(json)!;
The HTTP client used to send is not created fresh each time, it is shared by scheme plus host plus port, and browser environments take a separate route because connection pooling there is the browser's own business.
The local one, LocalApiProvider, resolves the dispatcher from the previous post out of the process, sets the token on it, and calls it directly:
executor.AccessToken = AccessToken;
executor.IsLocalCall = true;
return await executor.ExecuteAsync(request);
Session Management and Lifecycle said a business object's constructor takes "whether this is an in process call" as its fourth parameter, and here is where that flag's two ends meet: the local provider sets it to true explicitly, while anything arriving over HTTP keeps the default false, and it travels from here all the way to the line where the factory builds the business object. The server has a few maintenance methods that accept in process calls only, and this is the test they use, with the meaning of that check belonging to The API Payload Security Pipeline: Order, Protection Levels and Keys.
A local call does no encoding or encryption by default, with the format set to Plain and that whole stretch not running. The data never leaves memory from beginning to end, and paying for that stretch inside one process buys nothing.
With debug mode on, a local call runs the full encoding and encryption anyway, so during development, confirming that one's own data serializes correctly can be finished inside one process, without standing up a server for it.
Which route is taken is decided by the constructor
The ConnectType enum records the decision, and what actually takes effect is which constructor was used:
protected ApiConnector(Guid accessToken, ApiSessionContext session) // no address, local
protected ApiConnector(string endpoint, Guid accessToken, ApiSessionContext session) // an address, remote
Where the address comes from, and whether to go remote at all, are decided further upstream. ApiConnectValidator takes the configured address, treats a local path as local and a URL as remote, probing connectivity once and then sending one Ping. SupportedConnectTypes is the application's own declaration, so an application allowing remote only, handed a local path, is stopped right here rather than at the first failed call.
Without serialization, one free boundary goes missing
The difference between the two routes is not only whether a network is crossed.
| Remote | Local | |
|---|---|---|
| Who is on the other end | An HTTP endpoint | A dispatcher in the same process |
| Serialization | JSON in and JSON out | None at all |
| Encoding and encryption | As this call requires | The whole stretch skipped by default |
| How the token travels | An Authorization header |
A property on the dispatcher |
| The object sent | The other end receives a copy | The other end receives the same object |
That last one is the one that actually has to be handled. Over a network, serialization makes a copy as a side effect, so whatever the client does with its own copy afterward does not affect the other end. An in process call has no such step, so what the remote route gets for free the local route has to rebuild by hand.
The framework rebuilds it by swapping during the time zone stretch and swapping back when done, in PayloadZoneConverter:
case SaveRequest request when request.DataSet != null:
{
var original = request.DataSet;
request.DataSet = DateTimeZoneConverter.UserToUtc(original, timeZoneId);
return new PayloadSwap(() => request.DataSet = original);
}
What is swapped in is new data, and the original goes back when the call ends. Without that step the client's own data would be replaced by the UTC copy and then used to redraw the screen. The return direction works the same way, with the data in the response copied and then changed rather than changed in place, because in process that object is the server's.
Making the two routes indistinguishable to the client costs exactly this kind of detail, where one route has something already and the other has to manufacture it. Miss one and the symptom is not an error, it is a wrong value on the screen.
Anything that has to hold for both routes can only live at the one place both of them pass through, which is the Connector. Time zone conversion is hooked on that way, and the conversion itself is the subject of Time Zone Conversion of DateTime.
3. What belongs to one user, and what to the whole application
Where the state a Connector uses should live depends on how many users there are in one process.
A desktop application has one user per process, so putting the transport key and time zone obtained at login on static properties is entirely correct, and the least work. A server rendered web front end is not like that: one process serves many connections at once, each with its own login, and there is only one set of static properties. Whoever logs in later overwrites the previous person's key, and everyone overwritten then encrypts every subsequent call with somebody else's key, until they log in again.
The framework therefore collects the state belonging to one logged in user into a type, ApiSessionContext, holding two values: this connection's transport key, and this user's time zone.
The test is more general than the mechanism:
| The value is owned by | Examples | Where it belongs |
|---|---|---|
| The application itself | The API key, the service address | One copy per process |
| One logged in user | The transport key, the time zone | One copy per session |
| This one call | The access token | A constructor parameter bound to that Connector |
The application row is the direction that is easy to get wrong. An API key identifies which application is calling rather than who is calling, and its value is the same on every connection, so cutting it per session as well is not safer, it is just naming the wrong owner.
The token row is a third kind of owner: it is neither at the application layer nor hanging on session state, but carried in when each Connector is constructed and unchangeable afterward. Changing identity means changing Connector. The remote route turns it into a Bearer header and the local route sets it on the dispatcher, and the caller does not need to know which.
Where the token lives on the client
The two front end families answer differently, and the source is that same question of how many users share a process.
| Desktop family | Server rendered family | |
|---|---|---|
| Where the token lives | A static field | A per connection component passed down layer by layer |
| Session state | One copy shared by the whole process | One copy per connection |
| Where a Connector comes from | Built on the spot by a static method | An injected per connection factory |
| Changing the token | Also throws away the cached Connector and the definition cache | Rendering again, with the layers below fetching their own |
That last row follows directly from the earlier rule: a token is bound at construction and cannot be changed afterward, so changing one means replacing the whole Connector in hand.
Back to Northwind
The case study has four front ends: desktop, browser, iOS and Android. All four run the same Avalonia UI project and each has only a stretch of startup code, and only three things in that code concern this post: declaring remote support only, saying where the address and key are stored, and using the factory key as the seed for a first run. The UI project's project file has no entry for Bee.Api.Client, and the Connector it uses is built by the framework's UI assembly and handed to it.
Four front ends sharing one UI project holds partly because this client layer absorbs the differences. The only one taking a different path is the browser, whose runtime cannot produce an RSA key pair, so the key exchange at login is skipped entirely and calls asking for encryption are automatically stepped down a level by the Connector. Not one word of the calling code changes.
There are two things the case study cannot demonstrate. All four front ends declare remote support only, so the in process route is never once taken in this application, and it lives in tests and demonstration code. The case study also has no server rendered front end, so the line about many users in one process has nothing here that would even bring it about.
In closing
Collecting what the client has to do into one layer turns the number of places that can be written wrong from the number of front ends into one.
- Addressing, identity, transport, encoding, time zones, return types and errors, seven things on one path, which is why the JSON-RPC payload at both ends is assembled by the same code
- Local and remote are decided by a constructor overload, and the difference is not only whether a network is crossed, it includes the copy boundary the remote route gets for free and the local route has to manufacture
- Whether state gets one copy per session is decided by whether that value's owner is one user or the application
- The one point both routes pass through is the Connector, so anything that has to hold for both can only live there
The third point is the one most worth taking away, because the two ways of drawing that line wrong are not symmetric. Cutting an application level value per session costs nothing but a few identical copies, while making a user's value process wide costs somebody else's data. The first is waste and the second is an incident, and under single user testing the two behave identically.
The next post, The Error Contract and User Visible Messages, is about the other half, failure: what form a save stopped by a business rule takes on its way back to the caller, and which messages can go in front of a user unchanged.
📚 Part of the thirty-post series ERP Architect's Notes: Designing a Definition-Driven Framework
📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/day-17-client-connector
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet
Top comments (0)