Land fix batch 3's R-S: the schemas describe the bytes C# writes
New contracts/cultnet/cultnet.raw-document-header.schema.json for
CultNetRawDocumentHeader - it has no payload/payloadEncoding members at all,
so headers must not $ref the record schema (which required both). The
response page's own schema now lists every property CultNetSnapshotResponseRawV1Message
declares as `required` (present on the wire, nil included) rather than only
the four that happened to be non-nullable - MessagePack-CSharp's map-mode
formatter writes every declared key regardless of null, so `headers`/`documents`
are always both present, one an array and the other an explicit null by
projection. The old `oneOf: [{required:[headers]},{required:[documents]}]`
matched both branches on every real page (both keys present) and so never
validated anything; it is now `oneOf` over each key's type (array vs null),
which is mutually exclusive. cultnet.raw-document-record.schema.json gained
schemaName/schemaVersion/schemaContentHash (present on every real record, not
previously in the schema at all - any real page would have failed
additionalProperties:false on them). fieldPredicate's two oneOf branches each
gained `not: {required: [the other]}`, so `values` and `number` cannot both
be present (previously only `required` was checked, which does not exclude
the sibling key).
Added MiniJsonSchemaValidator, a deliberately small draft-2020-12 subset
validator (type/required/additionalProperties/properties/items/const/enum/
pattern/minLength/minItems/oneOf/$ref, same-document and cross-document) -
no schema-validation dependency existed in this repo, and this only needs to
cover what contracts/cultnet/*.schema.json actually use. CultNetSchemaContractTests
decodes real wire bytes from a live CultNetDatabaseServer (header projection,
document projection, and a hop with edges) through MessagePackSerializer.ConvertToJson
and checks them against the committed schema files, plus three
fieldPredicate-shape tests pinning the values/number exclusivity.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
753c750e37
commit
f47060d5c2
6 changed files with 480 additions and 21 deletions
35
contracts/cultnet/cultnet.raw-document-header.schema.json
Normal file
35
contracts/cultnet/cultnet.raw-document-header.schema.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json",
|
||||
"title": "CultNet Raw Document Header",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,17 +2,28 @@
|
|||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"schemaName",
|
||||
"schemaVersion",
|
||||
"schemaContentHash",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload"
|
||||
"payload",
|
||||
"sourceRuntimeId",
|
||||
"sourceAgentId",
|
||||
"sourceRole",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"schemaName": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
|
|
@ -21,11 +32,11 @@
|
|||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,16 @@
|
|||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{ "required": ["values"], "properties": { "op": { "const": "any_of" } } },
|
||||
{ "required": ["number"], "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } }
|
||||
{
|
||||
"required": ["values"],
|
||||
"not": { "required": ["number"] },
|
||||
"properties": { "op": { "const": "any_of" } }
|
||||
},
|
||||
{
|
||||
"required": ["number"],
|
||||
"not": { "required": ["values"] },
|
||||
"properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } }
|
||||
}
|
||||
]
|
||||
},
|
||||
"citation": {
|
||||
|
|
|
|||
|
|
@ -2,44 +2,58 @@
|
|||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
|
||||
"title": "CultNet Snapshot Response Raw Message v1",
|
||||
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "matched", "asOf"],
|
||||
"required": [
|
||||
"schemaVersion", "messageId", "matched", "asOf", "next",
|
||||
"headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{ "required": ["headers"] },
|
||||
{ "required": ["documents"] }
|
||||
{
|
||||
"required": ["headers", "documents"],
|
||||
"properties": { "headers": { "type": "array" }, "documents": { "type": "null" } }
|
||||
},
|
||||
{
|
||||
"required": ["headers", "documents"],
|
||||
"properties": { "headers": { "type": "null" }, "documents": { "type": "array" } }
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"matched": { "type": "integer", "minimum": 0 },
|
||||
"asOf": { "type": "integer", "minimum": 0 },
|
||||
"next": { "type": "string" },
|
||||
"next": { "type": ["string", "null"] },
|
||||
"headers": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
|
||||
"type": ["array", "null"],
|
||||
"items": { "$ref": "cultnet.raw-document-header.schema.json" }
|
||||
},
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"type": ["array", "null"],
|
||||
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
|
||||
},
|
||||
"edges": {
|
||||
"type": "array",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["from", "role", "to"],
|
||||
"required": ["from", "role", "to", "payloadEncoding", "payload"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
|
||||
"payloadEncoding": { "type": "string" },
|
||||
"payload": { "type": "string" }
|
||||
"payloadEncoding": { "type": ["string", "null"] },
|
||||
"payload": {
|
||||
"type": ["string", "null"],
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shardId": { "type": "string", "minLength": 1 },
|
||||
"shardEpoch": { "type": "integer" },
|
||||
"shardLogSequence": { "type": "integer" }
|
||||
"shardId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"shardEpoch": { "type": ["integer", "null"] },
|
||||
"shardLogSequence": { "type": ["integer", "null"] }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
165
tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs
Normal file
165
tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
// R-S (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3", F12): the committed
|
||||
// contracts/cultnet/*.schema.json files describe the bytes MessagePack-CSharp actually writes -
|
||||
// headers get their own schema (no payload/payloadEncoding, which CultNetRawDocumentHeader has
|
||||
// no members for at all), the snapshot page's shape matches map-mode output (every declared key
|
||||
// present, nil included), and fieldPredicate refuses values/number together. This decodes real
|
||||
// wire bytes from a live CultNetDatabaseServer and checks them against the committed schema
|
||||
// files with MiniJsonSchemaValidator, rather than asserting on the C# objects alone - a schema
|
||||
// file can drift from the bytes even while the C# types stay internally consistent, which is
|
||||
// exactly what F12 was.
|
||||
public sealed class CultNetSchemaContractTests
|
||||
{
|
||||
private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development();
|
||||
|
||||
private static string SchemaDir() => Path.Combine(RepoRoot(), "contracts", "cultnet");
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
|
||||
}
|
||||
|
||||
private static JsonElement ToJson<T>(T message) where T : ICultNetSchemaMessage
|
||||
{
|
||||
var bytes = CultNetSchemaMessageSerialization.Serialize(message);
|
||||
var json = MessagePackSerializer.ConvertToJson(bytes);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SnapshotResponseRawV1_HeaderProjectionRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
|
||||
{
|
||||
Name = "sword",
|
||||
Kind = "weapon",
|
||||
Mass = 3
|
||||
});
|
||||
using var server = new Server(cache, DevelopmentServerSecurity);
|
||||
using var databaseServer = new CultNetDatabaseServer(server, database);
|
||||
|
||||
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
|
||||
{
|
||||
MessageId = "header-projection",
|
||||
Selection = new CultNetSelection() // default projection is "header"
|
||||
});
|
||||
Assert.That(response.Headers, Is.Not.Null);
|
||||
Assert.That(response.Documents, Is.Null);
|
||||
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SnapshotResponseRawV1_DocumentProjectionRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
|
||||
{
|
||||
Name = "sword",
|
||||
Kind = "weapon",
|
||||
Mass = 3
|
||||
});
|
||||
using var server = new Server(cache, DevelopmentServerSecurity);
|
||||
using var databaseServer = new CultNetDatabaseServer(server, database);
|
||||
|
||||
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
|
||||
{
|
||||
MessageId = "document-projection",
|
||||
Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document }
|
||||
});
|
||||
Assert.That(response.Documents, Is.Not.Null);
|
||||
Assert.That(response.Headers, Is.Null);
|
||||
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SnapshotResponseRawV1_WithEdgesRealBytes_MatchesItsSchema()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
|
||||
await database.PutAsync(new CultRecordKey("target"), target);
|
||||
await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter
|
||||
{
|
||||
Name = "citer",
|
||||
Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
|
||||
});
|
||||
using var server = new Server(cache, DevelopmentServerSecurity);
|
||||
using var databaseServer = new CultNetDatabaseServer(server, database);
|
||||
|
||||
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
|
||||
{
|
||||
MessageId = "with-edges",
|
||||
Selection = new CultNetSelection
|
||||
{
|
||||
Cited = new CultNetIncoming { Role = "Design", Exists = true },
|
||||
Projection = CultNetSelectionProjections.Document
|
||||
}
|
||||
});
|
||||
Assert.That(response.Edges, Is.Not.Null.And.Length.GreaterThan(0));
|
||||
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
|
||||
}
|
||||
|
||||
// R-S: fieldPredicate is now exclusive - values and number may not both be present, even
|
||||
// though each alone satisfies one oneOf branch's `required`. Hand-built JSON, not a C# object,
|
||||
// because CultNetFieldPredicate itself has no door stopping this shape from being constructed
|
||||
// in memory - the door is CultNetSelectionValidation, a different layer; this test is about the
|
||||
// wire schema's own oneOf, independent of that door.
|
||||
[Test]
|
||||
public void FieldPredicate_ValuesAndNumberTogether_FailsItsSchema()
|
||||
{
|
||||
var instance = JsonDocument.Parse(
|
||||
"""{"index":"mass","op":"any_of","values":["1"],"number":"1"}""").RootElement;
|
||||
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
|
||||
|
||||
Assert.That(errors, Is.Not.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FieldPredicate_ValuesOnly_MatchesItsSchema()
|
||||
{
|
||||
var instance = JsonDocument.Parse(
|
||||
"""{"index":"kind","op":"any_of","values":["weapon"]}""").RootElement;
|
||||
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
|
||||
|
||||
Assert.That(errors, Is.Empty, string.Join("; ", errors));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FieldPredicate_NumberOnly_MatchesItsSchema()
|
||||
{
|
||||
var instance = JsonDocument.Parse(
|
||||
"""{"index":"mass","op":"gt","number":"1"}""").RootElement;
|
||||
|
||||
var validator = new MiniJsonSchemaValidator(SchemaDir());
|
||||
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
|
||||
|
||||
Assert.That(errors, Is.Empty, string.Join("; ", errors));
|
||||
}
|
||||
}
|
||||
}
|
||||
226
tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs
Normal file
226
tests/GameCult.Networking.Tests/MiniJsonSchemaValidator.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GameCult.Networking.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// A deliberately small JSON Schema (draft 2020-12) validator covering only the constructs
|
||||
/// contracts/cultnet/*.schema.json actually use: object/array/string/integer/boolean/null types
|
||||
/// (plain or as a ["type","null"] union), required, additionalProperties: false, properties,
|
||||
/// items, const, enum, pattern, minLength, minItems, oneOf, and $ref - same-document
|
||||
/// ("#/$defs/name"), cross-document ("other.schema.json"), and both ("other.schema.json#/$defs/name").
|
||||
/// Not a general validator: it exists for R-S (docs/cultnet-selection-cut.md, fix batch 3) to
|
||||
/// decode real C# wire bytes and check them against the committed schema files, which no
|
||||
/// dependency in this repo already does - see CultNetSchemaContractTests.
|
||||
/// </summary>
|
||||
internal sealed class MiniJsonSchemaValidator
|
||||
{
|
||||
private readonly string _schemaDir;
|
||||
private readonly Dictionary<string, JsonElement> _docCache = new(StringComparer.Ordinal);
|
||||
|
||||
public MiniJsonSchemaValidator(string schemaDir)
|
||||
{
|
||||
_schemaDir = schemaDir;
|
||||
}
|
||||
|
||||
/// <summary>Validates <paramref name="instance"/> against the named schema file's root schema. Throws with every collected error when invalid.</summary>
|
||||
public void AssertValid(JsonElement instance, string schemaFileName)
|
||||
{
|
||||
var errors = Validate(instance, schemaFileName);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{instance} does not satisfy {schemaFileName}:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Validates against a $defs entry of the named schema file, e.g. "cultnet.selection.schema.json#/$defs/fieldPredicate".</summary>
|
||||
public List<string> Validate(JsonElement instance, string refText)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var (schema, file) = Resolve(refText, currentFile: "");
|
||||
ValidateAgainst(instance, schema, file, "$", errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
private JsonElement LoadDoc(string fileName)
|
||||
{
|
||||
if (_docCache.TryGetValue(fileName, out var cached)) return cached;
|
||||
var path = Path.Combine(_schemaDir, fileName);
|
||||
var doc = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
|
||||
_docCache[fileName] = doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
private (JsonElement Schema, string File) Resolve(string refText, string currentFile)
|
||||
{
|
||||
var hash = refText.IndexOf('#');
|
||||
string file;
|
||||
string pointer;
|
||||
if (hash < 0)
|
||||
{
|
||||
file = refText;
|
||||
pointer = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
var filePart = refText[..hash];
|
||||
file = filePart.Length == 0 ? currentFile : filePart;
|
||||
pointer = refText[(hash + 1)..];
|
||||
}
|
||||
|
||||
var doc = LoadDoc(file);
|
||||
if (string.IsNullOrEmpty(pointer) || pointer == "/") return (doc, file);
|
||||
|
||||
var current = doc;
|
||||
foreach (var segment in pointer.TrimStart('/').Split('/'))
|
||||
{
|
||||
var key = segment.Replace("~1", "/").Replace("~0", "~");
|
||||
current = current.GetProperty(key);
|
||||
}
|
||||
|
||||
return (current, file);
|
||||
}
|
||||
|
||||
private void ValidateAgainst(JsonElement instance, JsonElement schema, string file, string path, List<string> errors)
|
||||
{
|
||||
if (schema.TryGetProperty("$ref", out var refProp))
|
||||
{
|
||||
var (resolved, resolvedFile) = Resolve(refProp.GetString()!, file);
|
||||
ValidateAgainst(instance, resolved, resolvedFile, path, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("oneOf", out var oneOf))
|
||||
{
|
||||
var matches = 0;
|
||||
var branchErrors = new List<string>();
|
||||
foreach (var branch in oneOf.EnumerateArray())
|
||||
{
|
||||
var local = new List<string>();
|
||||
ValidateAgainst(instance, branch, file, path, local);
|
||||
if (local.Count == 0) matches++;
|
||||
else branchErrors.AddRange(local);
|
||||
}
|
||||
|
||||
if (matches != 1)
|
||||
{
|
||||
errors.Add($"{path}: oneOf matched {matches} branches (want exactly 1); branch errors: {string.Join(" | ", branchErrors)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("const", out var constEl))
|
||||
{
|
||||
if (!JsonElementDeepEquals(instance, constEl))
|
||||
errors.Add($"{path}: expected const {constEl}, got {instance}");
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("enum", out var enumEl))
|
||||
{
|
||||
if (!enumEl.EnumerateArray().Any(candidate => JsonElementDeepEquals(instance, candidate)))
|
||||
errors.Add($"{path}: value {instance} is not one of the declared enum values");
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("type", out var typeEl))
|
||||
{
|
||||
var allowed = typeEl.ValueKind == JsonValueKind.Array
|
||||
? typeEl.EnumerateArray().Select(t => t.GetString()!).ToArray()
|
||||
: new[] { typeEl.GetString()! };
|
||||
if (!allowed.Any(t => MatchesType(instance, t)))
|
||||
errors.Add($"{path}: kind {instance.ValueKind} does not satisfy type [{string.Join(",", allowed)}]");
|
||||
}
|
||||
|
||||
// Null short-circuits the value-shaped checks below (a nilled key still satisfies a
|
||||
// ["...","null"] type, but has no length/properties/items of its own to check).
|
||||
if (instance.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (instance.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = instance.GetString()!;
|
||||
if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32())
|
||||
errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}");
|
||||
if (schema.TryGetProperty("pattern", out var pattern) && !Regex.IsMatch(value, pattern.GetString()!))
|
||||
errors.Add($"{path}: \"{value}\" does not match pattern {pattern.GetString()}");
|
||||
}
|
||||
|
||||
if (instance.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var items = instance.EnumerateArray().ToArray();
|
||||
if (schema.TryGetProperty("minItems", out var minItems) && items.Length < minItems.GetInt32())
|
||||
errors.Add($"{path}: array shorter than minItems {minItems.GetInt32()}");
|
||||
if (schema.TryGetProperty("items", out var itemSchema))
|
||||
{
|
||||
for (var i = 0; i < items.Length; i++)
|
||||
ValidateAgainst(items[i], itemSchema, file, $"{path}[{i}]", errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (instance.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var propertyNames = instance.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||
if (schema.TryGetProperty("required", out var required))
|
||||
{
|
||||
foreach (var name in required.EnumerateArray().Select(r => r.GetString()!))
|
||||
{
|
||||
if (!propertyNames.Contains(name))
|
||||
errors.Add($"{path}: missing required property \"{name}\"");
|
||||
}
|
||||
}
|
||||
|
||||
var declared = schema.TryGetProperty("properties", out var properties)
|
||||
? properties.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal)
|
||||
: new HashSet<string>(StringComparer.Ordinal);
|
||||
if (schema.TryGetProperty("additionalProperties", out var additional) &&
|
||||
additional.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
foreach (var name in propertyNames)
|
||||
{
|
||||
if (!declared.Contains(name))
|
||||
errors.Add($"{path}: property \"{name}\" is not declared and additionalProperties is false");
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var propertySchema in properties.EnumerateObject())
|
||||
{
|
||||
if (instance.TryGetProperty(propertySchema.Name, out var propertyValue))
|
||||
ValidateAgainst(propertyValue, propertySchema.Value, file, $"{path}.{propertySchema.Name}", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesType(JsonElement instance, string type) => type switch
|
||||
{
|
||||
"object" => instance.ValueKind == JsonValueKind.Object,
|
||||
"array" => instance.ValueKind == JsonValueKind.Array,
|
||||
"string" => instance.ValueKind == JsonValueKind.String,
|
||||
"boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False,
|
||||
"null" => instance.ValueKind == JsonValueKind.Null,
|
||||
"integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetInt64(out _),
|
||||
"number" => instance.ValueKind == JsonValueKind.Number,
|
||||
_ => throw new NotSupportedException($"Unsupported schema type \"{type}\".")
|
||||
};
|
||||
|
||||
private static bool JsonElementDeepEquals(JsonElement a, JsonElement b)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind) return false;
|
||||
return a.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => a.GetString() == b.GetString(),
|
||||
JsonValueKind.Number => a.GetRawText() == b.GetRawText(),
|
||||
JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true,
|
||||
_ => a.GetRawText() == b.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue