Read legacy Eve surface contracts
This commit is contained in:
parent
3bab9638c9
commit
511782ca42
5 changed files with 277 additions and 2 deletions
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using GameCult.Caching;
|
||||
using GameCult.Mesh;
|
||||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
|
||||
#nullable enable
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ namespace GameCult.Eve.Surface
|
|||
{
|
||||
[CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")]
|
||||
[MessagePackObject]
|
||||
[MessagePackFormatter(typeof(EveSurfaceDocumentCompatibilityFormatter))]
|
||||
public sealed class EveSurfaceDocument
|
||||
{
|
||||
public const string DefaultType = "surface-state";
|
||||
|
|
@ -289,6 +291,7 @@ namespace GameCult.Eve.Surface
|
|||
}
|
||||
|
||||
[MessagePackObject]
|
||||
[MessagePackFormatter(typeof(EveCommandTemplateCompatibilityFormatter))]
|
||||
public sealed class EveCommandTemplate
|
||||
{
|
||||
public EveCommandTemplate(CultMeshOperationBindingDescriptor operation)
|
||||
|
|
@ -318,7 +321,7 @@ namespace GameCult.Eve.Surface
|
|||
public string Transport => Operation.RouteHint.Description ?? "";
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.eve.command", SchemaId)]
|
||||
[CultDocument("gamecult.eve.command_invocation", SchemaId)]
|
||||
[MessagePackObject]
|
||||
public sealed class EveSurfaceCommandRequest
|
||||
{
|
||||
|
|
@ -411,4 +414,125 @@ namespace GameCult.Eve.Surface
|
|||
[IgnoreMember]
|
||||
public string CommandId => Operation.IdempotencyKey ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current Eve surface envelope and the original seven-field v1 envelope.
|
||||
/// </summary>
|
||||
public sealed class EveSurfaceDocumentCompatibilityFormatter : IMessagePackFormatter<EveSurfaceDocument?>
|
||||
{
|
||||
private const int LegacyFieldCount = 7;
|
||||
private const int CurrentFieldCount = 9;
|
||||
|
||||
public void Serialize(ref MessagePackWriter writer, EveSurfaceDocument? value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null) { writer.WriteNil(); return; }
|
||||
writer.WriteArrayHeader(CurrentFieldCount);
|
||||
writer.Write(value.Type);
|
||||
writer.Write(value.Schema);
|
||||
writer.Write(value.ProviderId);
|
||||
writer.Write(value.ProviderKind);
|
||||
writer.Write(value.Title);
|
||||
writer.Write(value.Version);
|
||||
writer.Write(value.UpdatedAtUtc);
|
||||
Formatter<EveSurfaceTree>(options).Serialize(ref writer, value.Surface, options);
|
||||
Formatter<IReadOnlyList<EveCommandTemplate>>(options).Serialize(ref writer, value.Commands, options);
|
||||
}
|
||||
|
||||
public EveSurfaceDocument? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.TryReadNil()) return null;
|
||||
if (reader.NextMessagePackType != MessagePackType.Array)
|
||||
throw new MessagePackSerializationException("Eve surface document must be an array.");
|
||||
|
||||
options.Security.DepthStep(ref reader);
|
||||
try
|
||||
{
|
||||
var fields = reader.ReadArrayHeader();
|
||||
if (fields == LegacyFieldCount)
|
||||
{
|
||||
return new EveSurfaceDocument(
|
||||
ReadString(ref reader), ReadString(ref reader), ReadString(ref reader),
|
||||
reader.ReadInt64(), ReadString(ref reader),
|
||||
Formatter<EveSurfaceTree>(options).Deserialize(ref reader, options)!,
|
||||
Formatter<IReadOnlyList<EveCommandTemplate>>(options).Deserialize(ref reader, options)
|
||||
?? Array.Empty<EveCommandTemplate>());
|
||||
}
|
||||
|
||||
var type = ReadString(ref reader, fields, 0);
|
||||
var schema = ReadString(ref reader, fields, 1);
|
||||
var providerId = ReadString(ref reader, fields, 2);
|
||||
var providerKind = ReadString(ref reader, fields, 3);
|
||||
var title = ReadString(ref reader, fields, 4);
|
||||
var version = fields > 5 ? reader.ReadInt64() : 0;
|
||||
var updatedAtUtc = ReadString(ref reader, fields, 6);
|
||||
var surface = fields > 7 ? Formatter<EveSurfaceTree>(options).Deserialize(ref reader, options) : null;
|
||||
var commands = fields > 8 ? Formatter<IReadOnlyList<EveCommandTemplate>>(options).Deserialize(ref reader, options) : null;
|
||||
for (var index = CurrentFieldCount; index < fields; index++) reader.Skip();
|
||||
return new EveSurfaceDocument(
|
||||
type, schema, providerId, providerKind, title, version, updatedAtUtc,
|
||||
surface ?? throw new MessagePackSerializationException("Eve surface tree is required."),
|
||||
commands ?? Array.Empty<EveCommandTemplate>());
|
||||
}
|
||||
finally { reader.Depth--; }
|
||||
}
|
||||
|
||||
private static IMessagePackFormatter<T> Formatter<T>(MessagePackSerializerOptions options) =>
|
||||
options.Resolver.GetFormatter<T>()
|
||||
?? throw new MessagePackSerializationException($"No formatter is registered for {typeof(T).FullName}.");
|
||||
|
||||
private static string ReadString(ref MessagePackReader reader) => reader.ReadString() ?? "";
|
||||
private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
|
||||
index < fields ? ReadString(ref reader) : "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads both direct legacy command fields and the current wrapped binding record.
|
||||
/// </summary>
|
||||
public sealed class EveCommandTemplateCompatibilityFormatter : IMessagePackFormatter<EveCommandTemplate?>
|
||||
{
|
||||
public void Serialize(ref MessagePackWriter writer, EveCommandTemplate? value, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (value == null) { writer.WriteNil(); return; }
|
||||
writer.WriteArrayHeader(1);
|
||||
BindingFormatter(options).Serialize(ref writer, value.OperationRecord, options);
|
||||
}
|
||||
|
||||
public EveCommandTemplate? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
if (reader.TryReadNil()) return null;
|
||||
if (reader.NextMessagePackType != MessagePackType.Array)
|
||||
throw new MessagePackSerializationException("Eve command template must be an array.");
|
||||
|
||||
options.Security.DepthStep(ref reader);
|
||||
try
|
||||
{
|
||||
var fields = reader.ReadArrayHeader();
|
||||
if (fields == 1 && reader.NextMessagePackType == MessagePackType.Array)
|
||||
{
|
||||
return new EveCommandTemplate(
|
||||
BindingFormatter(options).Deserialize(ref reader, options)
|
||||
?? new CultMeshOperationBindingRecord());
|
||||
}
|
||||
|
||||
var operationId = ReadString(ref reader, fields, 0);
|
||||
var label = ReadString(ref reader, fields, 1);
|
||||
var schemaId = ReadString(ref reader, fields, 2);
|
||||
var routeKind = ReadString(ref reader, fields, 3);
|
||||
var routeDescription = ReadString(ref reader, fields, 4);
|
||||
for (var index = 5; index < fields; index++) reader.Skip();
|
||||
return new EveCommandTemplate(new CultMeshOperationBindingRecord(
|
||||
operationId, label, schemaId, routeKind, routeDescription));
|
||||
}
|
||||
finally { reader.Depth--; }
|
||||
}
|
||||
|
||||
private static IMessagePackFormatter<CultMeshOperationBindingRecord> BindingFormatter(
|
||||
MessagePackSerializerOptions options) =>
|
||||
options.Resolver.GetFormatter<CultMeshOperationBindingRecord>()
|
||||
?? throw new MessagePackSerializationException("No CultMesh operation binding formatter is registered.");
|
||||
|
||||
private static string ReadString(ref MessagePackReader reader, int fields, int index) =>
|
||||
index < fields ? reader.ReadString() ?? "" : "";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"precompiledReferences": [
|
||||
"GameCult.Caching.dll",
|
||||
"GameCult.Mesh.dll",
|
||||
"MessagePack.Annotations.dll"
|
||||
"MessagePack.Annotations.dll",
|
||||
"MessagePack.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
|
|
|
|||
1
tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
vendored
Normal file
1
tests/GameCult.Eve.Surface.Serialization.Tests/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
!GameCult.Eve.Surface.Serialization.Tests.csproj
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
using System.Buffers;
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Mesh;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Eve.Surface.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class EveSurfaceSerializationCompatibilityTests
|
||||
{
|
||||
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
|
||||
|
||||
[Test]
|
||||
public void LegacySevenFieldSurfaceDocumentDeserializes()
|
||||
{
|
||||
var bytes = WriteLegacySurfaceDocument();
|
||||
|
||||
var document = MessagePackSerializer.Deserialize<EveSurfaceDocument>(bytes, Options);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(document.Type, Is.EqualTo(EveSurfaceDocument.DefaultType));
|
||||
Assert.That(document.Schema, Is.EqualTo(EveSurfaceDocument.SchemaId));
|
||||
Assert.That(document.ProviderId, Is.EqualTo("legacy-provider"));
|
||||
Assert.That(document.ProviderKind, Is.EqualTo("legacy-kind"));
|
||||
Assert.That(document.Title, Is.EqualTo("Legacy surface"));
|
||||
Assert.That(document.Version, Is.EqualTo(17));
|
||||
Assert.That(document.UpdatedAtUtc, Is.EqualTo("2026-07-13T12:00:00Z"));
|
||||
Assert.That(document.Surface.Id, Is.EqualTo("legacy-surface"));
|
||||
Assert.That(document.Commands, Has.Count.EqualTo(1));
|
||||
Assert.That(document.Commands[0].Command, Is.EqualTo("legacy.execute"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LegacyDirectFiveStringCommandTemplateDeserializes()
|
||||
{
|
||||
var bytes = WriteLegacyCommandTemplate();
|
||||
|
||||
var command = MessagePackSerializer.Deserialize<EveCommandTemplate>(bytes, Options);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(command.Command, Is.EqualTo("legacy.execute"));
|
||||
Assert.That(command.Label, Is.EqualTo("Execute legacy command"));
|
||||
Assert.That(command.Operation.SchemaId, Is.EqualTo("gamecult.legacy.command.v1"));
|
||||
Assert.That(command.OperationRecord.RouteKind, Is.EqualTo("cultmesh"));
|
||||
Assert.That(command.OperationRecord.RouteDescription, Is.EqualTo("legacy.commands"));
|
||||
Assert.That(command.Transport, Is.EqualTo("legacy.commands"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CurrentSerializationWritesNineFieldSurfaceAndWrappedStructuredCommandBinding()
|
||||
{
|
||||
var document = CreateCurrentDocument();
|
||||
|
||||
var bytes = MessagePackSerializer.Serialize(document, Options);
|
||||
var reader = new MessagePackReader(bytes);
|
||||
|
||||
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9));
|
||||
for (var field = 0; field < 8; field++)
|
||||
reader.Skip();
|
||||
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "commands collection");
|
||||
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(1), "command template wrapper");
|
||||
Assert.That(reader.NextMessagePackType, Is.EqualTo(MessagePackType.Array), "structured operation binding");
|
||||
|
||||
var roundTrip = MessagePackSerializer.Deserialize<EveSurfaceDocument>(bytes, Options);
|
||||
Assert.That(roundTrip.Commands[0].Command, Is.EqualTo("current.execute"));
|
||||
}
|
||||
|
||||
private static EveSurfaceDocument CreateCurrentDocument() => new(
|
||||
"current-provider",
|
||||
"current-kind",
|
||||
"Current surface",
|
||||
23,
|
||||
"2026-07-13T13:00:00Z",
|
||||
CreateSurfaceTree("current-surface"),
|
||||
new[]
|
||||
{
|
||||
new EveCommandTemplate(new CultMeshOperationBindingRecord(
|
||||
"current.execute",
|
||||
"Execute current command",
|
||||
"gamecult.current.command.v1",
|
||||
"cultmesh",
|
||||
"current.commands"))
|
||||
});
|
||||
|
||||
private static EveSurfaceTree CreateSurfaceTree(string id) => new(
|
||||
id,
|
||||
new EveSurfaceComponent(
|
||||
"root",
|
||||
"column",
|
||||
new Dictionary<string, string>(),
|
||||
Array.Empty<EveSurfaceComponent>()),
|
||||
Array.Empty<EveStyleToken>());
|
||||
|
||||
private static byte[] WriteLegacySurfaceDocument()
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
writer.WriteArrayHeader(7);
|
||||
writer.Write("legacy-provider");
|
||||
writer.Write("legacy-kind");
|
||||
writer.Write("Legacy surface");
|
||||
writer.Write(17L);
|
||||
writer.Write("2026-07-13T12:00:00Z");
|
||||
writer.WriteRaw(MessagePackSerializer.Serialize(CreateSurfaceTree("legacy-surface"), Options));
|
||||
writer.WriteArrayHeader(1);
|
||||
writer.WriteRaw(WriteLegacyCommandTemplate());
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] WriteLegacyCommandTemplate()
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
var writer = new MessagePackWriter(buffer);
|
||||
writer.WriteArrayHeader(5);
|
||||
writer.Write("legacy.execute");
|
||||
writer.Write("Execute legacy command");
|
||||
writer.Write("gamecult.legacy.command.v1");
|
||||
writer.Write("cultmesh");
|
||||
writer.Write("legacy.commands");
|
||||
writer.Flush();
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<BaseOutputPath>obj\bin\</BaseOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in a new issue