445 lines
18 KiB
C#
445 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Globalization;
|
|
|
|
namespace GameCult.Networking
|
|
{
|
|
/// <summary>
|
|
/// Registers exchange-safe schema metadata and produces discovery responses.
|
|
/// </summary>
|
|
public sealed class CultNetSchemaRegistry
|
|
{
|
|
private readonly Dictionary<string, RegisteredSchema> _entries = new Dictionary<string, RegisteredSchema>(StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// Creates a schema registry with optional initial entries.
|
|
/// </summary>
|
|
public CultNetSchemaRegistry(IEnumerable<CultNetSchemaRegistration>? entries = null)
|
|
{
|
|
if (entries == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
Register(entry);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds or replaces one schema registration.
|
|
/// </summary>
|
|
public CultNetSchemaRegistry Register(CultNetSchemaRegistration entry)
|
|
{
|
|
if (entry == null) throw new ArgumentNullException(nameof(entry));
|
|
if (string.IsNullOrWhiteSpace(entry.SchemaId)) throw new ArgumentException("SchemaId must be non-empty.", nameof(entry));
|
|
if (string.IsNullOrWhiteSpace(entry.Kind)) throw new ArgumentException("Kind must be non-empty.", nameof(entry));
|
|
if (entry.WireContracts == null || entry.WireContracts.Length == 0)
|
|
{
|
|
throw new ArgumentException("WireContracts must contain at least one entry.", nameof(entry));
|
|
}
|
|
|
|
var canonicalSchemaJson = CanonicalizeJson(entry.SchemaJson);
|
|
var contentHash = ComputeSha256Hex(canonicalSchemaJson);
|
|
|
|
_entries[entry.SchemaId] = new RegisteredSchema(entry, canonicalSchemaJson, contentHash);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets one schema descriptor by schema identifier.
|
|
/// </summary>
|
|
public CultNetSchemaDescriptor? Get(string schemaId, bool includeSchemaJson = false)
|
|
{
|
|
if (schemaId == null) throw new ArgumentNullException(nameof(schemaId));
|
|
return _entries.TryGetValue(schemaId, out var entry)
|
|
? entry.ToDescriptor(includeSchemaJson)
|
|
: null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists schema descriptors, optionally filtered by identifier or kind.
|
|
/// </summary>
|
|
public CultNetSchemaDescriptor[] List(
|
|
bool includeSchemaJson = false,
|
|
IEnumerable<string>? schemaIds = null,
|
|
IEnumerable<string>? kinds = null)
|
|
{
|
|
var requestedSchemaIds = schemaIds != null
|
|
? new HashSet<string>(schemaIds, StringComparer.Ordinal)
|
|
: null;
|
|
var requestedKinds = kinds != null
|
|
? new HashSet<string>(kinds, StringComparer.Ordinal)
|
|
: null;
|
|
|
|
return _entries.Values
|
|
.Where(entry =>
|
|
(requestedSchemaIds == null || requestedSchemaIds.Contains(entry.SchemaId)) &&
|
|
(requestedKinds == null || requestedKinds.Contains(entry.Kind)))
|
|
.Select(entry => entry.ToDescriptor(includeSchemaJson))
|
|
.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a schema catalog response for a request.
|
|
/// </summary>
|
|
public CultNetSchemaCatalogResponseMessage CreateCatalogResponse(
|
|
CultNetSchemaCatalogRequestMessage request)
|
|
{
|
|
if (request == null) throw new ArgumentNullException(nameof(request));
|
|
|
|
return new CultNetSchemaCatalogResponseMessage
|
|
{
|
|
MessageId = request.MessageId,
|
|
Schemas = List(
|
|
includeSchemaJson: request.IncludeSchemaJson,
|
|
schemaIds: request.SchemaIds,
|
|
kinds: request.Kinds)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a registry populated from embedded CultNet schema resources.
|
|
/// </summary>
|
|
public static CultNetSchemaRegistry CreateBuiltIn()
|
|
{
|
|
return new CultNetSchemaRegistry(BuiltInSchemaManifest.Select(LoadEmbeddedRegistration));
|
|
}
|
|
|
|
private static readonly Lazy<CultNetSchemaRegistry> BuiltInRegistry =
|
|
new Lazy<CultNetSchemaRegistry>(CreateBuiltIn);
|
|
|
|
/// <summary>
|
|
/// Gets the lazily initialized built-in schema registry.
|
|
/// </summary>
|
|
public static CultNetSchemaRegistry BuiltIn => BuiltInRegistry.Value;
|
|
|
|
private static CultNetSchemaRegistration LoadEmbeddedRegistration(SchemaResourceSpec spec)
|
|
{
|
|
var resourceName = $"GameCult.Networking.Contracts.{spec.FileName}";
|
|
using var stream = typeof(CultNetSchemaRegistry).Assembly.GetManifestResourceStream(resourceName)
|
|
?? throw new InvalidOperationException($"Missing embedded CultNet schema resource \"{resourceName}\".");
|
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
|
var schemaJson = reader.ReadToEnd();
|
|
|
|
using var document = JsonDocument.Parse(schemaJson);
|
|
var root = document.RootElement;
|
|
var schemaId = root.GetProperty("$id").GetString()
|
|
?? throw new InvalidOperationException($"Schema resource \"{resourceName}\" is missing $id.");
|
|
var title = root.TryGetProperty("title", out var titleElement) ? titleElement.GetString() : null;
|
|
|
|
return new CultNetSchemaRegistration
|
|
{
|
|
SchemaId = schemaId,
|
|
Kind = spec.Kind,
|
|
SchemaVersion = spec.SchemaVersion,
|
|
DocumentType = spec.DocumentType,
|
|
Title = title,
|
|
WireContracts = spec.WireContracts.ToArray(),
|
|
SchemaJson = schemaJson
|
|
};
|
|
}
|
|
|
|
private static string CanonicalizeJson(string schemaJson)
|
|
{
|
|
using var document = JsonDocument.Parse(schemaJson);
|
|
using var stream = new MemoryStream();
|
|
using (var writer = new Utf8JsonWriter(stream))
|
|
{
|
|
WriteCanonicalElement(document.RootElement, writer);
|
|
}
|
|
|
|
return Encoding.UTF8.GetString(stream.ToArray());
|
|
}
|
|
|
|
private static void WriteCanonicalElement(JsonElement element, Utf8JsonWriter writer)
|
|
{
|
|
switch (element.ValueKind)
|
|
{
|
|
case JsonValueKind.Object:
|
|
writer.WriteStartObject();
|
|
foreach (var property in element.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal))
|
|
{
|
|
writer.WritePropertyName(property.Name);
|
|
WriteCanonicalElement(property.Value, writer);
|
|
}
|
|
|
|
writer.WriteEndObject();
|
|
break;
|
|
case JsonValueKind.Array:
|
|
writer.WriteStartArray();
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
WriteCanonicalElement(item, writer);
|
|
}
|
|
|
|
writer.WriteEndArray();
|
|
break;
|
|
default:
|
|
element.WriteTo(writer);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static string ComputeSha256Hex(string canonicalSchemaJson)
|
|
{
|
|
using var sha = SHA256.Create();
|
|
var bytes = Encoding.UTF8.GetBytes(canonicalSchemaJson);
|
|
var hash = sha.ComputeHash(bytes);
|
|
var builder = new StringBuilder(hash.Length * 2);
|
|
foreach (var value in hash)
|
|
{
|
|
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private sealed class RegisteredSchema
|
|
{
|
|
public RegisteredSchema(CultNetSchemaRegistration registration, string canonicalSchemaJson, string contentHash)
|
|
{
|
|
SchemaId = registration.SchemaId;
|
|
Kind = registration.Kind;
|
|
SchemaVersion = registration.SchemaVersion;
|
|
DocumentType = registration.DocumentType;
|
|
Title = registration.Title;
|
|
WireContracts = registration.WireContracts.ToArray();
|
|
CanonicalSchemaJson = canonicalSchemaJson;
|
|
ContentHash = contentHash;
|
|
}
|
|
|
|
public string SchemaId { get; }
|
|
public string Kind { get; }
|
|
public string? SchemaVersion { get; }
|
|
public string? DocumentType { get; }
|
|
public string? Title { get; }
|
|
public string[] WireContracts { get; }
|
|
public string CanonicalSchemaJson { get; }
|
|
public string ContentHash { get; }
|
|
|
|
public CultNetSchemaDescriptor ToDescriptor(bool includeSchemaJson)
|
|
{
|
|
return new CultNetSchemaDescriptor
|
|
{
|
|
SchemaId = SchemaId,
|
|
Kind = Kind,
|
|
SchemaVersion = SchemaVersion,
|
|
DocumentType = DocumentType,
|
|
Title = Title,
|
|
WireContracts = WireContracts.ToArray(),
|
|
ContentHash = ContentHash,
|
|
SchemaJson = includeSchemaJson ? CanonicalSchemaJson : null
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class SchemaResourceSpec
|
|
{
|
|
public string FileName { get; set; } = string.Empty;
|
|
public string Kind { get; set; } = string.Empty;
|
|
public string[] WireContracts { get; set; } = Array.Empty<string>();
|
|
public string? SchemaVersion { get; set; }
|
|
public string? DocumentType { get; set; }
|
|
}
|
|
|
|
private static readonly SchemaResourceSpec[] BuiltInSchemaManifest =
|
|
{
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.hello.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.Hello,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.document-mutation-contract.schema.json",
|
|
Kind = "shared_contract",
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.transport-profile.schema.json",
|
|
Kind = "shared_contract",
|
|
SchemaVersion = "cultnet.transport_profile.v0",
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.login.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.Login,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.register.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.Register,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.verify.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.Verify,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.login-success.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.LoginSuccess,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.error.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.Error,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.sample-change-name.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.SampleChangeName,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.sample-chat.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.SampleChat,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.operation-request.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.OperationRequest,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.operation-response.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.OperationResponse,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.document-delete.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.DocumentDelete,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.raw-document-record.schema.json",
|
|
Kind = "shared_contract",
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.document-put-raw.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.DocumentPutRaw,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.snapshot-request.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.SnapshotRequest,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.snapshot-response-raw.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.SnapshotResponseRaw,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.schema-descriptor.schema.json",
|
|
Kind = "shared_contract",
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.schema-catalog-request.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.SchemaCatalogRequest,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.schema-catalog-response.schema.json",
|
|
Kind = "wire_message",
|
|
SchemaVersion = CultNetSchemaVersions.SchemaCatalogResponse,
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "ghostlight.agent-state.schema.json",
|
|
Kind = "document_payload",
|
|
SchemaVersion = "ghostlight.agent_state.v0",
|
|
DocumentType = "ghostlight.agent-state",
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
},
|
|
new SchemaResourceSpec
|
|
{
|
|
FileName = "cultnet.witness-artifact-bundle.schema.json",
|
|
Kind = "document_payload",
|
|
SchemaVersion = CultNetSchemaVersions.WitnessArtifactBundle,
|
|
DocumentType = "cultnet.witness-artifact-bundle",
|
|
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// One exchange-safe schema registration.
|
|
/// </summary>
|
|
public sealed class CultNetSchemaRegistration
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the schema identifier.
|
|
/// </summary>
|
|
public string SchemaId { get; set; } = string.Empty;
|
|
/// <summary>
|
|
/// Gets or sets the schema kind.
|
|
/// </summary>
|
|
public string Kind { get; set; } = string.Empty;
|
|
/// <summary>
|
|
/// Gets or sets the wire contracts implemented by the schema.
|
|
/// </summary>
|
|
public string[] WireContracts { get; set; } = Array.Empty<string>();
|
|
/// <summary>
|
|
/// Gets or sets the schema document.
|
|
/// </summary>
|
|
public string SchemaJson { get; set; } = string.Empty;
|
|
/// <summary>
|
|
/// Gets or sets the message or document schema version.
|
|
/// </summary>
|
|
public string? SchemaVersion { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the document type associated with the schema.
|
|
/// </summary>
|
|
public string? DocumentType { get; set; }
|
|
/// <summary>
|
|
/// Gets or sets the display title.
|
|
/// </summary>
|
|
public string? Title { get; set; }
|
|
}
|
|
}
|