Reference

.NET client

Package Trax.ClientNamespace Trax.ClientSource TraxClient.cs, TraxDocument.cs, TraxHttpTransport.cs, TraxAnswer.cs, Optional.cs, TraxJson.cs, GraphQLSubscriptionSocket.cs

Trax.Client sends a Trax host's GraphQL documents from .NET: a query or mutation as an HTTP POST, a subscription over one graphql-transport-ws socket. A document goes by the persisted id the host registered it under, or as text to a host that runs it unregistered. Every answer is a value: data, the host's errors, or a transport failure. A refusal never throws.

dotnet add package Trax.Client

It references the base class library and nothing else, so a terminal tool, a MAUI app or a Blazor WebAssembly app can take it. It is marked trimmable and AOT-compatible, and builds with the trim and AOT analyzers reporting nothing; see Trimming and native AOT for what a trimmed app passes it. The typed documents it sends are generated: trax client --operations --lang csharp writes a TraxDocument<TVariables, TResult> per document with its variables and result records, in the namespace --namespace names. Trax.StateMachine.Client posts and subscribes through this package too.

Signatures

public sealed class TraxClient
{
    public TraxClient(ITraxTransport transport, TraxClientOptions? options = null);
    public static readonly IReadOnlyList<TimeSpan> DefaultResubscribeDelays;
    public ITraxTransport Transport { get; }
 
    public Task<TraxAnswer<TResult>> SendAsync<TVariables, TResult>(
        TraxDocument<TVariables, TResult> document, TVariables variables, CancellationToken cancellationToken = default);
    public IDisposable Subscribe<TVariables, TResult>(
        TraxDocument<TVariables, TResult> document, TVariables variables, Action<TraxStreamEvent<TResult>> onEvent);
}
 
public abstract record TraxDocument(string Name, TraxOperationKind Kind, string? Id, string Text);
public sealed record TraxDocument<TVariables, TResult>(string Name, TraxOperationKind Kind, string? Id, string Text)
    : TraxDocument(Name, Kind, Id, Text);
 
public interface ITraxTransport
{
    Task<TraxAnswer> ExecuteAsync(TraxRequest request, CancellationToken cancellationToken = default);
    IDisposable Subscribe(TraxRequest request, ISubscriptionSink sink);
}
 
public sealed class TraxHttpTransport : ITraxTransport
{
    public TraxHttpTransport(TraxHttpTransportOptions options);
    public JsonObject Payload(TraxRequest request);
}
 
public sealed class GraphQLSubscriptionSocket : ISubscriptionSocket, IAsyncDisposable
{
    public GraphQLSubscriptionSocket(GraphQLSubscriptionSocketOptions options);
    public IDisposable Subscribe(JsonObject payload, ISubscriptionSink sink);
}
 
public readonly record struct Optional<T>
{
    public Optional(T value);
    public bool HasValue { get; }
    public T Value { get; }
}

Members

Sending

MemberWhat it is
TraxClientSends documents through an ITraxTransport, writing variables and reading data with TraxJson.Options unless TraxClientOptions.Json says otherwise. It never talks to the network itself. Built in an app that turned reflection-based serialization off, it throws ArgumentException unless TraxClientOptions.Json names a resolver.
SendAsync(document, variables, cancellationToken)Sends a query or mutation and reads the answer into a TraxAnswer<TResult>. It throws only when cancellationToken is cancelled, and, before anything is sent, ArgumentException for a subscription document (which Subscribe follows) or for variables that do not serialize to a JSON object. Data that does not fit TResult, or that the options cannot read at all, is a Failure, not a throw.
Subscribe(document, variables, onEvent)Follows a subscription until the returned handle is disposed, and calls onEvent with each TraxStreamEvent<TResult>, one call at a time. Throws ArgumentException for a document that is not a subscription, or for variables that do not serialize to a JSON object. An onEvent that throws ends its stream: the last call it gets is Ended, whose Failure names the exception. Other streams go on.
TransportThe transport the client sends through.
DefaultResubscribeDelays1, 2, then 5 seconds: the delays before subscribing again after the socket dropped a stream.
TraxClientOptionsJson, the serializer options (TraxJson.Options when null), which the client reads through a copy that leaves every unset Optional<T> out, whatever their resolver; ResubscribeDelays, whose last delay repeats for as long as the stream is followed (DefaultResubscribeDelays when null or empty); Time, the TimeProvider the delays run on (the system's when null).

Documents

MemberWhat it is
TraxDocumentOne document: its operation's Name, sent as operationName; its Kind; the persisted Id, or null for a document a host runs as text and never registers; and its Text, exactly as the id was computed from it.
TraxDocument<TVariables, TResult>A document whose variables are a TVariables and whose data is read into a TResult. Generated code declares one per document.
TraxOperationKindQuery, Mutation or Subscription.
TraxNoVariablesThe variables of a document that declares none, sent as {}. TraxNoVariables.Instance is its one value.
TraxRequestA Document and its Variables as a JsonObject: what a transport is handed.
Optional<T>A variable or input field that may be left out, which GraphQL tells apart from null. default is left out, and the host uses the argument's default or leaves the value as it is; any value converts to one that is sent, null included, which clears it. HasValue says whether it is sent and Value holds it.
TraxJson.OptionsThe web defaults (camelCase, case-insensitive reads), with every Optional<T> property that holds no value left out of the JSON. It reads types by reflection. A TraxClient leaves an unset Optional<T> out with any options given in TraxClientOptions.Json too, a source-generated context included; only JSON written outside the client, with options it never saw, carries an unset one as default(T).
OptionalJsonConverterFactoryThe converter Optional<T> names in its [JsonConverter], public so a source-generated context in another assembly can create it. Nothing calls it directly.
TraxEnumConverter<TEnum>The converter a generated enum names: reads and writes each member by its JsonStringEnumMemberName. A name the enum lacks, which a newer host may send, is read as a value no member holds instead of failing the answer, and written back as that name. It remembers up to 256 such names per enum and refuses the answer past that.
TraxEnumIsUnknown(value): whether a value is none of the enum's members, a name the host sent that this client lacks.

Answers

MemberWhat it is
TraxAnswer<T>A document's answer: Data read into the result, the Errors the host answered with, or the Failure that kept an answer from arriving. Succeeded is true when data arrived with no error. Code is TRANSPORT_ERROR for a failure, else the first error's code. Message is the failure's message or every error's, one per line.
TraxAnswerAn answer as it arrived, what an ITraxTransport returns: Data as JSON, Errors and Failure. TraxAnswer.Of(json) reads an answer's JSON and is null when it holds neither data nor errors; TraxAnswer.Failed(message, status) is one that never arrived.
TraxGraphQLErrorOne error: its Message, its Code (extensions.code, such as CLIENT_OUTDATED or AUTH_NOT_AUTHORIZED), its Path and its Extensions as sent.
TraxTransportFailureA request that got no GraphQL answer: its Message, and the HTTP Status when a response arrived. TraxTransportFailure.Code is TRANSPORT_ERROR, the code the state-machine clients report a transport failure under.
TraxStreamEvent<T>What a followed stream delivers: Next with one frame's Data; Interrupted with the Reason the socket dropped it; Resumed once it is subscribed again, before its first Next; Ended with the Errors or the Failure it ended on, both empty only when the host completed it.

Transports

MemberWhat it is
ITraxTransportHow a request reaches a host: ExecuteAsync for a query or mutation, which answers a refusal or an unreachable host as a TraxAnswer and throws only on cancellation, and Subscribe for a stream. An app can give TraxClient its own: a recording, a fake, a wrapper that counts what is in flight.
TraxHttpTransportThe transport over HTTP POST and a socket. Payload(request) is the body or subscribe payload it sends: id or query, with operationName and variables. Subscribe sends it over the Socket, or fails the sink at once with a SocketUnavailableException when there is none.
ExecuteAsync(request, cancellationToken)POSTs the payload and reads the answer. A body with errors is an answer whatever its status, since a host answers a coded refusal with the status it chose, and a success with data is one too. Everything else is a Failure: a status that is not success with no errors (whatever data came with it), a host that could not be reached, a body that breaks off, a body larger than MaxResponseSize (refused as it arrives, never read whole), and a request, body included, that outlasts the HttpClient's Timeout. It throws only when cancellationToken is cancelled.
TraxHttpTransportOptionsHttp, the HttpClient the requests go through (set its authentication on it, a default header or a delegating handler, and turn its automatic redirects off; its Timeout bounds each request, body included); the GraphQL Endpoint; the Socket subscriptions go over; ById, which says whether a document goes by its id (by default every document with an id does; a host that registered only some of them, or none, names which); MaxResponseSize, the largest body read from an answer (DefaultMaxResponseSize, 16 MiB); and AllowInsecureTransport. Without it the transport refuses, when it is built, an http endpoint whose host is not this machine, since whatever authenticates the requests would cross the network in the clear.

The socket

MemberWhat it is
GraphQLSubscriptionSocketA graphql-transport-ws socket. It connects when the first subscription opens and closes when the last one ends. Each subscribe message carries the payload it is given. It does not reconnect by itself: a closed or broken connection (a close, a dropped network, a ping left unanswered, a message that is not graphql-transport-ws) fails every open subscription, with a SocketClosedException when it closed, and whoever subscribed decides when to subscribe again (TraxClient does, after its resubscribe delays). A failed connection is disposed. A sink that throws ends only its own subscription: the socket completes it with the host and hands the exception to that sink's Error.
GraphQLSubscriptionSocketOptionsThe socket Endpoint; ConnectionParams, the connection_init payload, read on every connection so a rotating token stays fresh; ConfigureSocket, to set headers, a proxy or client certificates on each ClientWebSocket, run after the keep-alive is set; ConnectTimeout (10 seconds), how long a connection may take to be acknowledged; KeepAliveInterval (15 seconds), how often the socket pings the host, and KeepAliveTimeout (15 seconds), how long a ping may go unanswered before the connection counts as broken, which is how a host that dropped off the network without closing is found (neither is set in a browser); MaxMessageSize (16 MiB), the largest message read, past which the connection fails rather than reading on; and AllowInsecureTransport, without which a ws endpoint whose host is not this machine is refused.
ISubscriptionSocketThe socket seam: Subscribe(payload, sink), where payload is the subscribe message's payload (id or query, with operationName and variables). The returned handle ends the subscription.
ISubscriptionSinkWhat a subscription delivers to: Next with each GraphQL result, Error when the server refused it, the connection closed or broke, or there is no socket, Complete when the server ended it, and Started once the subscribe message went out over an acknowledged connection (nothing by default). GraphQLSubscriptionSocket calls Started before the first Next, and never calls one subscription's sink from two threads at once.
SocketClosedExceptionThe Error a sink gets when the socket closed, before it was acknowledged or while subscribed, or the host refused to open it. CloseStatus is the close code the server sent, if any; HttpStatus the status it refused the upgrade with, if it did. Refused is true for a 4400 to 4499 close (4401 unauthorized, 4403 forbidden) and for an upgrade answered 401 or 403, the host refusing the connection itself: subscribing again gets the same answer.
SocketUnavailableExceptionThe Error a sink gets when there is no socket to follow the subscription over: the transport has none, or the socket was disposed.
SubscriptionErrorExceptionThe Error a sink gets when the server ends a subscription with GraphQL errors, which it carries as Errors (empty when the server sent none).

Behavior

  • Totality. SendAsync and ExecuteAsync answer every refusal and every transport failure as a value: an unreachable host, a broken or oversized body, an answer that does not fit the result. A timeout the caller did not ask for is a Failure; a cancellation the caller asked for throws. The only other throws are SendAsync's ArgumentExceptions, for a subscription document or for variables that do not serialize to a JSON object, before anything is sent.
  • By id or by text. A document with an id goes by its id unless ById says otherwise, and its text is then not sent at all. A host that requires persisted operations accepts only those. Either way the request carries operationName.
  • Streams. A Subscribe stream ends for good (Ended) when the host completes it (no errors, no failure), answers a frame with errors or refuses the subscription (Errors, or a Failure when the refusal carried none), or with a Failure when it closes the socket with a 44xx code, answers the upgrade 401 or 403, sends a frame that does not fit the result, when the transport has no socket or the socket was disposed, or when onEvent throws. Anything else that drops the stream, a close, a broken connection, an unanswered ping or a malformed message, is Interrupted, said once, and the stream is subscribed again after the next of ResubscribeDelays, starting over at the first once a subscription starts. Resumed follows, before the new subscription's first Next; frames sent meanwhile are lost, so read again what the stream may have missed. Disposing the handle ends the stream and cancels a pending resubscribe.
  • Optional fields. A generated variables or input record types a field the host may leave as it is as an Optional<T>, so MaxActiveJobs = null sends "maxActiveJobs": null and leaving it unset sends nothing, with TraxJson.Options or any options in TraxClientOptions.Json. Read back, a member that is absent is unset and one that is null is a null that was sent.
  • Redirects. An HttpClient follows redirects by default and drops only Authorization when it does, so a default header such as X-Api-Key would follow a redirect to whatever host it names. Build the client on a handler with AllowAutoRedirect = false, as the example does; a GraphQL endpoint has no reason to redirect a POST. An answer from another scheme, host or port than the endpoint's, which is what a client that followed a redirect hands back, is a Failure rather than an answer, but by then the request has reached that host. GraphQLSubscriptionSocket follows no redirect of its upgrade either, since connection_init carries the credential: a redirected upgrade fails the connection. Outside a browser it upgrades through its own handler, so a proxy, credentials, client certificates, a certificate callback or cookies set in ConfigureSocket are moved onto that handler.

Trimming and native AOT

TraxJson.Options reads types by reflection, which a trimmed or native AOT app turns off by default, and is annotated so. Such an app passes TraxClientOptions.Json with a source-generated JsonSerializerContext as its resolver; the client adds what leaves unset Optional<T> fields out. The context lists the generated records and every T an Optional<T> wraps, since it cannot see through Optional<T>'s converter. Without one, and with reflection off, the TraxClient constructor throws ArgumentException rather than failing on the first request.

[JsonSerializable(typeof(DisableManifestVariables))]
[JsonSerializable(typeof(DisableManifestResult))]
[JsonSerializable(typeof(string))] // each T an Optional<T> wraps
internal sealed partial class ShopJson : JsonSerializerContext;
 
var client = new TraxClient(
    transport,
    new TraxClientOptions { Json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = ShopJson.Default } }
);

Example

using System.Text.Json.Nodes;
using Trax.Client;
 
// No automatic redirects: a redirect would carry X-Api-Key to whatever host it names.
using var http = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false });
http.DefaultRequestHeaders.Add("X-Api-Key", configuration["Trax:ApiKey"]);
 
await using var socket = new GraphQLSubscriptionSocket(
    new GraphQLSubscriptionSocketOptions
    {
        Endpoint = new Uri("wss://shop.example/trax/graphql"),
        ConnectionParams = _ =>
            ValueTask.FromResult<JsonObject?>(new JsonObject { ["apiKey"] = configuration["Trax:ApiKey"] }),
    }
);
var client = new TraxClient(
    new TraxHttpTransport(
        new TraxHttpTransportOptions
        {
            Http = http,
            Endpoint = new Uri("https://shop.example/trax/graphql"),
            Socket = socket,
        }
    )
);
 
// DisableManifest and OnTrainStateChanged are generated documents.
var answer = await client.SendAsync(Documents.DisableManifest, new DisableManifestVariables("nightly-sync"));
if (!answer.Succeeded)
    Console.WriteLine($"{answer.Code}: {answer.Message}"); // a refusal or a transport failure, never a throw
 
using var feed = client.Subscribe(
    Documents.OnTrainStateChanged,
    TraxNoVariables.Instance,
    streamEvent =>
    {
        switch (streamEvent)
        {
            case TraxStreamEvent<OnTrainStateChangedResult>.Next next:
                Console.WriteLine($"{next.Data.OnTrainStateChanged.TrainName}: {next.Data.OnTrainStateChanged.TrainState}");
                break;
            case TraxStreamEvent<OnTrainStateChangedResult>.Resumed:
                Console.WriteLine("Reconnected: read the runs again.");
                break;
        }
    }
);

See also