Skip to content

Serialization

samgerene edited this page Oct 4, 2026 · 5 revisions

Serialization

ArgusTransfer handles two layers of serialization: wire serialization (the ARGUS/1.0 protocol format) and body serialization (the message payload).

Wire Serializers

The ArgusRequestSerializer and ArgusResponseSerializer handle reading and writing the full ARGUS/1.0 wire format. These are used internally by the client and server -- you do not need to interact with them directly in most cases.

Each serializer provides:

  • A byte-safe Stream API, used by the client and the pipe host: WriteAsync(Stream, message) / ReadAsync(Stream) (plus WriteAsync(Stream, response, acceptContentType) on the response serializer). Bodies are written and read as raw bytes, Content-Length and chunk sizes are byte counts, and compressed bodies are supported.
  • A synchronous string API for testing and in-memory use: Write(message) / Read(string text)
  • StreamWriter / StreamReader overloads, kept for compatibility: WriteAsync(StreamWriter, message) flushes the writer and writes bytes to its BaseStream (byte-safe); ReadAsync(StreamReader) and Read(string) count Content-Length in UTF-8 bytes.

Use the Stream overloads for transport code. The text-based paths (string, StreamReader, synchronous StreamWriter) decode chunked data as UTF-8, so they only round-trip streamed bodies that are valid UTF-8 text, and they throw InvalidOperationException for a message with a Content-Encoding.

Situation Stream overloads Text-based overloads
Non-ASCII string body ✓ ✓
Binary streamed body ✓ corrupted (non-UTF-8 bytes)
Content-Encoding (compression) ✓ InvalidOperationException
Body shorter than Content-Length EndOfStreamException truncated body returned

The serializers' ContentEncodings property lists the encodings used to compress and decompress bodies on the Stream overloads (gzip by default). See Compression.

Body Serializers

Body serializers handle the conversion of the message body for a specific content type. They implement the IArgusBodySerializer interface:

public interface IArgusBodySerializer
{
    string ContentType { get; }

    string WriteBody(string body);

    string ReadBody(string body);
}
Member Description
ContentType The MIME type this serializer handles (e.g., "text/plain", "application/json")
WriteBody Transforms the body before writing to the wire
ReadBody Transforms the body after reading from the wire

Built-in: PlainTextArgusBodySerializer

ArgusTransfer ships with a PlainTextArgusBodySerializer that handles text/plain content. It performs a pass-through -- the body is written and read as-is without transformation.

This serializer is registered automatically when you call AddArgusPipeHost() or AddArgusPlainTextProtocol().

Body Serializer Registry

The IArgusBodySerializerRegistry resolves serializers by content type:

public interface IArgusBodySerializerRegistry
{
    IArgusBodySerializer DefaultSerializer { get; }

    bool TryGetSerializer(string contentType, out IArgusBodySerializer serializer);

    IReadOnlyCollection<IArgusBodySerializer> GetSerializers() => [this.DefaultSerializer];
}
  • DefaultSerializer -- the fallback serializer when no content type is specified
  • TryGetSerializer -- looks up a serializer matching the given content type
  • GetSerializers -- all registered serializers, used to match wildcard media ranges such as application/* in an Accept header. It has a default implementation, so existing custom registries keep compiling; without an override, wildcards only match the default serializer (exact media types still work through TryGetSerializer). ArgusBodySerializerRegistry returns every registered serializer.

The server uses the registry to:

  1. Select the appropriate serializer for reading request bodies based on the Content-Type header
  2. Negotiate the response serializer from the Accept header -- returning 406 NotAcceptable only if no registered serializer is acceptable (see Content Negotiation Flow)

Registering Serializers

Register the default plain text protocol:

services.AddArgusPlainTextProtocol();

This is called automatically by AddArgusPipeHost(), so you typically do not need to call it explicitly.

Add a custom body serializer (for example the JsonArgusBodySerializer written in the next section -- the library itself ships only PlainTextArgusBodySerializer):

services.AddArgusBodySerializer<JsonArgusBodySerializer>();

Serializers are deduplicated -- registering the same type twice has no effect.

Writing a Custom Body Serializer

To support a custom content type, implement IArgusBodySerializer:

public class JsonArgusBodySerializer : IArgusBodySerializer
{
    public string ContentType => "application/json";

    public string WriteBody(string body)
    {
        // Transform body before writing to wire (if needed)
        return body;
    }

    public string ReadBody(string body)
    {
        // Transform body after reading from wire (if needed)
        return body;
    }
}

Register it during service configuration:

services.AddArgusBodySerializer<JsonArgusBodySerializer>();

Chunked Transfer Encoding

For large or streaming payloads, ArgusTransfer supports chunked transfer encoding via the ArgusChunkedEncoding class. Chunked encoding is used automatically when a message has a BodyStream set instead of a Body string.

Chunked format:

{hex-chunk-size}\r\n
{chunk-data}\r\n
...
0\r\n
\r\n

On the client side, use the stream overloads of PostAsync, PutAsync, or PatchAsync to send chunked bodies:

using var stream = File.OpenRead("data.bin");
var response = await client.PostAsync("/upload", stream, "application/octet-stream");

The server enforces MaxRequestBodySize on chunked bodies to prevent denial-of-service via unbounded streams.

Structured Error Bodies

For machine-readable error responses, ArgusProblemDetails (an RFC 7807 model under ArgusTransfer.Protocol) serializes itself to a JSON string with System.Text.Json and sets Content-Type: application/problem+json on the response. Because body serializers transform strings, the default PlainTextArgusBodySerializer passes that JSON through unchanged -- no extra serializer registration is needed:

context.Response = ArgusProblemDetails.BadRequest("name is required");

// or, with the correlation token as the problem's instance:
context.Problem(ArgusStatusCode.BadRequest, "name is required");

See Error Handling for the full property model, the factory methods, client-side parsing with TryRead, and how Extensions are serialized.

Content Negotiation Flow

When a request arrives at the server:

  1. If the request has a Content-Type header, the registry resolves the appropriate serializer for reading the body
  2. If the request has an Accept header, the server negotiates the response serializer from it -- if no registered serializer is acceptable, the server returns 406 NotAcceptable
  3. If no Accept header is present (the default for a new ArgusRequest), any media type is acceptable and the default serializer is used
  4. The response is written with the negotiated serializer, whose content type becomes the response's Content-Type. A handler that sets Content-Type itself keeps it, and the body is written with the serializer registered for that content type

Accept header rules

Accept is a comma-separated list of media ranges, each with an optional quality value, as in HTTP:

Accept: application/json, text/plain;q=0.5, */*;q=0.1
  • A range is type/subtype, type/* or */* (* alone is read as */*). Matching is case-insensitive, and parameters other than q (such as charset) are ignored.
  • q is a number from 0 to 1 (default 1). q=0 means "not acceptable" and excludes the matching serializers.
  • A serializer's quality comes from the most specific range that matches its content type, so */*, text/plain;q=0 accepts everything except plain text.
  • The acceptable serializer with the highest quality wins. Ties go to the more specific range, then to the range listed first, then to the default serializer.
  • Malformed ranges (no /, an invalid q) are skipped. A header without any valid range is treated as no preference.
Accept (registered: text/plain default, application/json) Response
(none) text/plain
application/json, text/plain application/json
text/plain;q=0.5, application/json application/json
application/* application/json
*/* text/plain (the default)
application/xml 406 NotAcceptable
application/json;q=0 406 NotAcceptable

The same rules apply when you call ArgusResponseSerializer.Write(response, accept) or WriteAsync(stream, response, accept, …) with a registry: the accept argument may be a full Accept header value. If nothing is acceptable, these methods fall back to the default serializer; only the host answers 406.

In earlier versions, ArgusRequest always sent Accept: text/plain and the host matched the whole header value literally, so lists and quality values always produced 406. Code that relied on request.Accept being "text/plain" by default now sees null.

Clone this wiki locally