diff --git a/packages/GameCult.Thing.Contract/Command.cs b/packages/GameCult.Thing.Contract/Command.cs index dbeff22..193574c 100644 --- a/packages/GameCult.Thing.Contract/Command.cs +++ b/packages/GameCult.Thing.Contract/Command.cs @@ -104,16 +104,32 @@ public sealed class CommandDecl } } +/// +/// A typed record as its MessagePack bytes. Every empty memory (ReadOnlyMemory<byte>.Empty, a default view, +/// an empty array) is an empty payload, as Rust's empty Vec<u8> is. A nil on the wire is not an empty +/// payload: it decodes to a default view the constructor cannot tell from an empty one, so the constructor notes it and +/// the decode door refuses it once the value is read. +/// [MessagePackObject] -public sealed class RecordPayload +public sealed class RecordPayload : IMessagePackSerializationCallbackReceiver { + private readonly bool _viewWasDefault; + [Key(0)] public string Schema { get; } [Key(1)] public ReadOnlyMemory Bytes { get; } public RecordPayload(string schema, ReadOnlyMemory bytes) { Schema = ThingDomain.Present(schema, "Schema"); - Bytes = ThingDomain.Present(bytes, "Bytes").ToArray(); + _viewWasDefault = bytes.Equals(default(ReadOnlyMemory)); + Bytes = bytes.ToArray(); + } + + void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize() { } + + void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize() + { + if (_viewWasDefault) throw new ThingDomainException("Bytes", "present"); } } diff --git a/packages/GameCult.Thing.Contract/Domain.cs b/packages/GameCult.Thing.Contract/Domain.cs index 77c0700..ca23b16 100644 --- a/packages/GameCult.Thing.Contract/Domain.cs +++ b/packages/GameCult.Thing.Contract/Domain.cs @@ -49,15 +49,6 @@ public static class ThingDomain public static T Present(T? value, string slot) where T : class => value ?? throw new ThingDomainException(slot, "present"); - /// - /// A required byte slot. A null on the wire decodes to a default view with no backing store, so the default view is - /// refused as nil; an empty array is a present, empty payload. - /// - public static ReadOnlyMemory Present(ReadOnlyMemory value, string slot) => - value.Equals(default(ReadOnlyMemory)) - ? throw new ThingDomainException(slot, "present") - : value; - /// /// A required list slot: a read-only copy, so the caller's list cannot change the value afterwards. A null list or /// a null item is refused naming the slot. diff --git a/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs b/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs index 0d8a69f..cce5758 100644 --- a/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs +++ b/tests/GameCult.Thing.Contract.Tests/ReadOnlyListTests.cs @@ -1,3 +1,4 @@ +using MessagePack; using System.Collections; using System.Reflection; @@ -165,11 +166,31 @@ public sealed class ReadOnlyListTests } [Test] - public void ANilPayloadIsRefusedAndAnEmptyOneIsKeptThroughTheWire() + public void EveryEmptyMemoryIsAnEmptyPayloadThatWritesAsRustsEmptyVec() { - var error = Assert.Throws(() => new RecordPayload("s", default))!; - Assert.That(error.Domain, Is.EqualTo("Bytes")); - Assert.That(error.Bound, Is.EqualTo("present")); + // Rust's empty Vec is bin8 of length 0 (c4 00); a RecordPayload is [schema, bytes]. + var rust = new byte[] { 0x92, 0xA1, (byte)'s', 0xC4, 0x00 }; + var empties = new Dictionary> + { + ["ReadOnlyMemory.Empty"] = ReadOnlyMemory.Empty, + ["Memory.Empty"] = Memory.Empty, + ["default"] = default(ReadOnlyMemory), + ["Array.Empty"] = Array.Empty(), + ["new byte[0]"] = new byte[0], + }; + foreach (var (name, memory) in empties) + { + var payload = new RecordPayload("s", memory); + Assert.That(payload.Bytes.Length, Is.EqualTo(0), name); + Assert.That(Wire.Write(payload), Is.EqualTo(rust), name); + Assert.That(Wire.Write(Wire.Read(typeof(RecordPayload), rust)), Is.EqualTo(rust), name); + } + } + + [Test] + public void ANilPayloadOnTheWireIsRefusedAndAnEmptyOneIsKeptThroughTheWire() + { + Assert.Throws(() => Wire.Read(typeof(RecordPayload), new byte[] { 0x92, 0xA1, (byte)'s', 0xC0 })); var empty = new CommandInvocation("i", "k", 1, "go", new RecordPayload("s", Array.Empty()), new List(), "c", "t", null); var decoded = (CommandInvocation)Wire.Read(typeof(CommandInvocation), Wire.Write(empty));