diff --git a/.gts-spec-version b/.gts-spec-version index b0d2e47..6cf9fa5 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.14.2 +v0.14.4 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b9ad361 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + + + net8.0 + enable + enable + 0.1.0 + + + diff --git a/Gts.Application/Gts.Application.csproj b/Gts.Application/Gts.Application.csproj index 0ae542f..4cbf0be 100644 --- a/Gts.Application/Gts.Application.csproj +++ b/Gts.Application/Gts.Application.csproj @@ -1,9 +1,6 @@ - net8.0 - enable - enable Gts.Application diff --git a/Gts.Application/GtsEntityOperations.cs b/Gts.Application/GtsEntityOperations.cs index 810ef3a..f625a22 100644 --- a/Gts.Application/GtsEntityOperations.cs +++ b/Gts.Application/GtsEntityOperations.cs @@ -7,13 +7,14 @@ namespace Gts.Application; /// Adds entities to a registry with the same rules as the GTS HTTP API. public static class GtsEntityOperations { - public sealed record AddResult(bool Ok, string Id, string? SchemaId, bool IsSchema, string? Error); + public sealed record AddResult(bool Ok, string Id, string? SchemaId, bool IsSchema, string? Error, bool Conflict = false); public static async Task TryAddAsync( GtsRegistry registry, JsonObject body, bool validate, GtsExtractOptions? extractOptions = null, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -24,7 +25,7 @@ public static async Task TryAddAsync( if (!entity.IsSchema) { if (string.IsNullOrEmpty(entity.SelectedEntityField)) - return new AddResult(false, "", null, false, "Instance must have an id field"); + return new AddResult(false, "", null, false, "Unable to detect GTS ID in instance entity"); if (entity.GtsId is { IsInstance: true } instanceId && instanceId.Segments.Count == 1) return new AddResult(false, instanceId.Id, null, false, "Single-segment instance IDs are not allowed"); } @@ -39,11 +40,17 @@ public static async Task TryAddAsync( var rawId = body.TryGetPropertyValue("$id", out var idn) && idn is JsonValue idValue && idValue.TryGetValue(out var id) ? id : null; - if (validate && !string.IsNullOrEmpty(rawId) && rawId.StartsWith("gts.", StringComparison.Ordinal) && - !rawId.StartsWith("gts://", StringComparison.Ordinal)) + if (validate && !string.IsNullOrEmpty(rawId) && rawId.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) && + !rawId.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) return new AddResult(false, "", null, true, "Schema $id must use gts:// URI format, not plain gts. prefix"); - if (!TryGetSupportedDialect(body, out var dialectError)) + if (!GtsTypeSchema.TryGetSupportedDialect(body, out _, out var dialectError)) return new AddResult(false, entity.GtsId?.Id ?? "", null, true, dialectError); + var keywordErrors = GtsSchemaKeywordValidator.Validate(body); + if (keywordErrors.Count > 0) + return new AddResult(false, entity.GtsId?.Id ?? "", null, true, string.Join("; ", keywordErrors)); + var refErrors = GtsRefValidator.ValidatePatterns(body, entity.GtsId?.Id ?? ""); + if (refErrors.Count > 0) + return new AddResult(false, entity.GtsId?.Id ?? "", null, true, "x-gts-ref validation failed: " + string.Join("; ", refErrors)); } try @@ -55,59 +62,129 @@ public static async Task TryAddAsync( return new AddResult(false, "", null, entity.IsSchema, ex.Message); } + GtsJsonEntity? existing = entity.GtsId is not null + ? await registry.GetAsync(entity.GtsId).ConfigureAwait(false) + : !string.IsNullOrEmpty(extract.Id) + ? await registry.GetByInstanceIdAsync(extract.Id).ConfigureAwait(false) + : null; + if (existing is not null) + { + if (JsonNode.DeepEquals(existing.Content, entity.Content) && !validate) + return new AddResult(true, extract.Id ?? entity.GtsId?.Id ?? "", entity.SchemaId, entity.IsSchema, null); + if (!JsonNode.DeepEquals(existing.Content, entity.Content)) + return new AddResult(false, extract.Id ?? entity.GtsId?.Id ?? "", entity.SchemaId, entity.IsSchema, + "Entity already exists with different content", true); + } + if (entity.IsSchema && entity.GtsId is not null) { if (validate) { - var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken) + var registered = await registry.SnapshotForReadAsync().ConfigureAwait(false); + var constraintErrors = GtsRefValidator.ValidateConstraints(entity.Content, entity.GtsId.Id, registered, refValidationMode); + if (constraintErrors.Count > 0) + return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, "x-gts-ref validation failed: " + string.Join("; ", constraintErrors)); + var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode) .ConfigureAwait(false); if (!schemaVr.Ok) { var msg = schemaVr.Errors is { Count: > 0 } ? string.Join("; ", schemaVr.Errors) - : (schemaVr.FailureReason ?? "Schema validation failed"); + : (schemaVr.FailureReason?.ToWire() ?? "Schema validation failed"); return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, msg); } } - await registry.SaveAsync(entity).ConfigureAwait(false); + if (await registry.TrySaveAsync(entity).ConfigureAwait(false) == GtsSaveOutcome.Conflict) + return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, "Entity already exists with different content", true); var schemaIdOut = entity.GtsId.Id; return new AddResult(true, schemaIdOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, true, null); } - await registry.SaveAsync(entity).ConfigureAwait(false); - - if (validate && !entity.IsSchema && entity.GtsId is not null) + if (validate && !entity.IsSchema) { - var vr = await registry.ValidateInstanceAsync(entity.GtsId.Id, cancellationToken).ConfigureAwait(false); - if (!vr.Ok) - return new AddResult(false, entity.GtsId.Id, entity.SchemaId, false, vr.FailureReason ?? "Validation failed"); + if (string.IsNullOrEmpty(entity.SchemaId) || !GtsId.TryParse(entity.SchemaId, out var schemaId) || schemaId is null || !schemaId.IsType) + return new AddResult(false, entity.GtsId?.Id ?? extract.Id ?? "", entity.SchemaId, false, "Unable to determine instance type"); + var validation = await registry.ValidateJsonAsync(entity.Content, schemaId, entity.GtsId?.Id ?? extract.Id, cancellationToken, refValidationMode) + .ConfigureAwait(false); + if (!validation.Ok) + { + var error = validation.SchemaErrors is { Count: > 0 } + ? string.Join("; ", validation.SchemaErrors) + : validation.FailureReason?.ToWire() ?? "Validation failed"; + return new AddResult(false, entity.GtsId?.Id ?? extract.Id ?? "", entity.SchemaId, false, error); + } } var idOut = entity.GtsId?.Id ?? extract.Id ?? ""; + if (await registry.TrySaveAsync(entity).ConfigureAwait(false) == GtsSaveOutcome.Conflict) + return new AddResult(false, idOut, entity.SchemaId, entity.IsSchema, "Entity already exists with different content", true); return new AddResult(true, idOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, entity.IsSchema, null); } - private static bool TryGetSupportedDialect(JsonObject body, out string? error) + /// + /// Runs the structural checks for a batch Type Schema entry (canonical $schema/$id, dialect, GTS + /// keyword placement, $ref format and x-gts-ref patterns) and builds the entity WITHOUT touching the + /// store. Returns the entity on success or an error message. Mirrors the schema branch of + /// so a batch entry is checked exactly like a single POST /entities schema. + /// + public static (GtsJsonEntity? Entity, string? Error) PrepareSchema(JsonObject body, GtsExtractOptions? extractOptions = null) { - error = null; - if (!body.TryGetPropertyValue("$schema", out var node) || node is not JsonValue value || - !value.TryGetValue(out var dialect) || string.IsNullOrEmpty(dialect)) + var opt = extractOptions ?? GtsExtractOptions.Default; + var entity = GtsJsonEntity.ExtractEntity(body, opt); + if (!entity.IsSchema || entity.GtsId is null) + return (null, "Unable to detect GTS ID in schema"); + + var rawId = body.TryGetPropertyValue("$id", out var idn) && idn is JsonValue idValue && idValue.TryGetValue(out var id) + ? id + : null; + if (!string.IsNullOrEmpty(rawId) && rawId.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) && + !rawId.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + return (null, "Schema $id must use gts:// URI format, not plain gts. prefix"); + if (!GtsTypeSchema.TryGetSupportedDialect(body, out _, out var dialectError)) + return (null, dialectError); + var keywordErrors = GtsSchemaKeywordValidator.Validate(body); + if (keywordErrors.Count > 0) + return (null, string.Join("; ", keywordErrors)); + var refErrors = GtsRefValidator.ValidatePatterns(body, entity.GtsId.Id); + if (refErrors.Count > 0) + return (null, "x-gts-ref validation failed: " + string.Join("; ", refErrors)); + try { - error = "Schema must contain a supported $schema dialect"; - return false; + GtsSchemaRefFormatValidator.ValidateRefs(body); } + catch (Exception ex) + { + return (null, ex.Message); + } + return (entity, null); + } - if (dialect is "http://json-schema.org/draft-07/schema#" or - "https://json-schema.org/draft-07/schema#" or - "https://json-schema.org/draft/2019-09/schema" or - "https://json-schema.org/draft/2019-09/schema#" or - "https://json-schema.org/draft/2020-12/schema" or - "https://json-schema.org/draft/2020-12/schema#") - return true; - - error = $"Unsupported JSON Schema dialect: {dialect}"; - return false; + /// + /// Runs the semantic validation of a staged schema (x-gts-ref existence, derivation/traits) against the + /// current staged+committed set, returning an error message on failure or null on success. Does not + /// mutate the store: the caller commits the staged entry on success or discards it on failure. + /// + public static async Task ValidateStagedSchemaAsync( + GtsRegistry registry, + GtsJsonEntity entity, + GtsRefValidationMode refValidationMode, + CancellationToken cancellationToken = default, + string? stagingSessionId = null) + { + if (entity.GtsId is null) + return "Unable to detect GTS ID in schema"; + var registered = await registry.SnapshotForReadAsync(stagingSessionId).ConfigureAwait(false); + var constraintErrors = GtsRefValidator.ValidateConstraints(entity.Content, entity.GtsId.Id, registered, refValidationMode); + if (constraintErrors.Count > 0) + return "x-gts-ref validation failed: " + string.Join("; ", constraintErrors); + var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode, stagingSessionId) + .ConfigureAwait(false); + if (!schemaVr.Ok) + return schemaVr.Errors is { Count: > 0 } + ? string.Join("; ", schemaVr.Errors) + : (schemaVr.FailureReason?.ToWire() ?? "Schema validation failed"); + return null; } } diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs index a122431..3448a8f 100644 --- a/Gts.Application/GtsHttpApiExtensions.cs +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -1,16 +1,16 @@ using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using Gts; using Gts.Extraction; using Gts.Store; +using Microsoft.AspNetCore.Diagnostics; namespace Gts.Application; /// Maps the GTS HTTP API onto (shared by Gts.Server and CLI server). -public static class GtsHttpApiExtensions +public static partial class GtsHttpApiExtensions { - private static readonly GtsExtractOptions HttpExtractOptions = new() + internal static readonly GtsExtractOptions HttpExtractOptions = new() { AllowDoubleDollarKeywords = false, EntityIdPropertyNames = @@ -21,6 +21,16 @@ public static class GtsHttpApiExtensions public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry registry) { + app.UseExceptionHandler(errorApp => errorApp.Run(async context => + { + var error = context.Features.Get()?.Error; + var (status, message) = error is JsonException or BadHttpRequestException + ? (StatusCodes.Status422UnprocessableEntity, "Invalid JSON request body") + : (StatusCodes.Status500InternalServerError, "Internal server error"); + context.Response.StatusCode = status; + await context.Response.WriteAsJsonAsync(new { ok = false, error = message }).ConfigureAwait(false); + })); + app.MapGet("/entities", async (int? limit) => { var l = limit is >= 1 and <= 1000 ? limit.Value : 100; @@ -46,24 +56,26 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi id = e.GtsId?.Id ?? id, schema_id = string.IsNullOrEmpty(e.SchemaId) ? null : e.SchemaId, is_schema = e.IsSchema, - content = JsonNode.Parse(e.Content.ToJsonString()) + content = e.Content.DeepClone() }); }); app.MapPost("/entities", async (HttpRequest req) => { - var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase); + var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(req.Query["validation"], "true", StringComparison.OrdinalIgnoreCase); + var refValidationMode = NormalizeRefValidationMode(req.Query["gts-ref-validation"]); var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); if (node is not JsonObject body) return Results.Json(new { ok = false, error = "Body must be a JSON object", is_schema = false, is_type_schema = false }, statusCode: StatusCodes.Status422UnprocessableEntity); - var result = await GtsEntityOperations.TryAddAsync(registry, body, validate, HttpExtractOptions).ConfigureAwait(false); + var result = await GtsEntityOperations.TryAddAsync(registry, body, validate, HttpExtractOptions, refValidationMode).ConfigureAwait(false); if (!result.Ok) return Results.Json(new { ok = false, error = result.Error, is_schema = result.IsSchema, is_type_schema = result.IsSchema }, - statusCode: StatusCodes.Status422UnprocessableEntity); + statusCode: result.Conflict ? StatusCodes.Status409Conflict : StatusCodes.Status422UnprocessableEntity); - return Results.Json(new { ok = true, id = result.Id, schema_id = result.SchemaId, is_schema = result.IsSchema, is_type_schema = result.IsSchema }); + return Results.Json(new { ok = true, id = result.Id, type_id = result.SchemaId, schema_id = result.SchemaId, is_schema = result.IsSchema, is_type_schema = result.IsSchema }); }); app.MapPost("/entities/bulk", async (HttpRequest req) => @@ -89,47 +101,153 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi app.MapPost("/type-schemas", async (HttpRequest req) => { + // ?validate / ?gts-ref-validation apply to every batch entry exactly + // as on POST /entities; a bogus gts-ref-validation is rejected before + // any entry is registered. + var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(req.Query["validation"], "true", StringComparison.OrdinalIgnoreCase); + if (!GtsRefValidationModes.TryParse(req.Query["gts-ref-validation"].ToString(), out var refValidationMode)) + return Results.Json(new { ok = false, error = "Invalid gts-ref-validation mode" }, statusCode: 422); + var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); if (node is not JsonArray schemas) return Results.Json(new { ok = false, error = "Request body must be a JSON array of GTS Type Schemas" }, statusCode: 422); - var results = new List(); + var results = new object?[schemas.Count]; var allOk = true; - foreach (var item in schemas) + + if (!validate) { - if (item is not JsonObject schema) + // Without validation, forward references are allowed and order does not matter, so + // each entry is registered directly. + for (var i = 0; i < schemas.Count; i++) { - allOk = false; - results.Add(new { ok = false, type_id = (string?)null, error = "GTS Type Schema entry must be a JSON object" }); - continue; + if (schemas[i] is not JsonObject schema) + { + results[i] = new { ok = false, type_id = (string?)null, error = "GTS Type Schema entry must be a JSON object" }; + allOk = false; + continue; + } + if (!GtsTypeSchema.TryGetDeclaredTypeId(schema, out var typeId, out var identityError)) + { + results[i] = new { ok = false, type_id = (string?)null, error = identityError }; + allOk = false; + continue; + } + var result = await GtsEntityOperations.TryAddAsync(registry, schema, false, HttpExtractOptions, refValidationMode).ConfigureAwait(false); + results[i] = new { ok = result.Ok, type_id = (string?)typeId, error = result.Error }; + if (!result.Ok) + allOk = false; } + return Results.Json(new { ok = allOk, results }); + } - if (!TryGetString(schema, "$schema", out _)) + // validate=true: two-phase so the outcome is order-independent and nothing invalid is ever + // published. Stage every structurally-valid entry (invisible to public reads) under a single + // per-batch staging session, validate each against that session's staged set (so intra-batch + // references/ancestors resolve regardless of order, while another request's staged entries stay + // invisible), then publish the survivors atomically and discard the ones that failed. Every staged + // token is tracked so a throw anywhere below still discards the leftovers in the finally block + // instead of leaking unvalidated entries into the internal read overlay. + var stagingSession = Guid.NewGuid().ToString("N"); + var staged = new List<(int Index, string TypeId, GtsJsonEntity Entity, string Token)>(); + var pending = new HashSet(StringComparer.Ordinal); + try + { + for (var i = 0; i < schemas.Count; i++) { - allOk = false; - results.Add(new { ok = false, type_id = (string?)null, error = "GTS Type Schema must contain a top-level $schema field" }); - continue; + if (schemas[i] is not JsonObject schema) + { + results[i] = new { ok = false, type_id = (string?)null, error = "GTS Type Schema entry must be a JSON object" }; + allOk = false; + continue; + } + if (!GtsTypeSchema.TryGetDeclaredTypeId(schema, out var typeId, out var identityError)) + { + results[i] = new { ok = false, type_id = (string?)null, error = identityError }; + allOk = false; + continue; + } + var (entity, prepError) = GtsEntityOperations.PrepareSchema(schema, HttpExtractOptions); + if (entity is null) + { + results[i] = new { ok = false, type_id = (string?)typeId, error = prepError }; + allOk = false; + continue; + } + // Reject a conflicting re-registration against the committed store before staging. + var existing = entity.GtsId is not null ? await registry.GetAsync(entity.GtsId).ConfigureAwait(false) : null; + if (existing is not null && !JsonNode.DeepEquals(existing.Content, entity.Content)) + { + results[i] = new { ok = false, type_id = (string?)typeId, error = "Entity already exists with different content" }; + allOk = false; + continue; + } + var token = await registry.StageAsync(entity, stagingSession).ConfigureAwait(false); + pending.Add(token); + staged.Add((i, typeId, entity, token)); } - if (!TryGetString(schema, "$id", out var embeddedId) || !embeddedId.StartsWith("gts://", StringComparison.Ordinal)) + // Phase 2: validate the staged entries, discarding failures and RE-validating the + // survivors against the now-smaller staged set until a round produces no new failures. + // This stops an entry that only validated because a sibling was staged (e.g. its parent + // or $ref target) from being committed after that sibling has itself been discarded. + var survivors = staged; + while (true) { - allOk = false; - results.Add(new { ok = false, type_id = (string?)null, error = "GTS Type Schema must contain a top-level $id in gts:// form" }); - continue; + var stillGood = new List<(int Index, string TypeId, GtsJsonEntity Entity, string Token)>(survivors.Count); + var failedThisRound = new List<(int Index, string TypeId, string Token, string Error)>(); + foreach (var (index, typeId, entity, token) in survivors) + { + var error = await GtsEntityOperations.ValidateStagedSchemaAsync(registry, entity, refValidationMode, default, stagingSession).ConfigureAwait(false); + if (error is null) + stillGood.Add((index, typeId, entity, token)); + else + failedThisRound.Add((index, typeId, token, error)); + } + + if (failedThisRound.Count == 0) + break; + + foreach (var (index, typeId, token, error) in failedThisRound) + { + await registry.DiscardStagedAsync(token).ConfigureAwait(false); + pending.Remove(token); + results[index] = new { ok = false, type_id = (string?)typeId, error = (string?)error }; + allOk = false; + } + survivors = stillGood; } - var typeId = embeddedId["gts://".Length..]; - if (!GtsId.TryParse(typeId, out var parsed) || parsed is null || !parsed.IsType) + // Phase 3: publish the entries that passed AS ONE ATOMIC UNIT. A per-entry commit loop could + // publish a dependent schema after a concurrent batch won its parent or $ref target id with + // different content than the dependent was validated against; the store-level batch + // compare-and-swap instead checks every target and publishes none if any conflicts. + var survivorTokens = survivors.Select(s => s.Token).ToList(); + var outcomes = await registry.CommitStagedBatchAsync(survivorTokens).ConfigureAwait(false); + for (var s = 0; s < survivors.Count; s++) { - allOk = false; - results.Add(new { ok = false, type_id = typeId, error = $"Invalid GTS Type Schema $id: '{embeddedId}'" }); - continue; + var (index, typeId, _, token) = survivors[s]; + if (outcomes[s] == GtsSaveOutcome.Conflict) + { + // Nothing was published for a conflicting entry; leave its token in `pending` so the + // finally discards it (on a batch conflict the whole survivor set stays staged). + results[index] = new { ok = false, type_id = (string?)typeId, error = (string?)"Entity already exists with different content" }; + allOk = false; + } + else + { + pending.Remove(token); + results[index] = new { ok = true, type_id = (string?)typeId, error = (string?)null }; + } } - - var result = await GtsEntityOperations.TryAddAsync(registry, schema, validate: false, HttpExtractOptions).ConfigureAwait(false); - if (!result.Ok) - allOk = false; - results.Add(new { ok = result.Ok, type_id = typeId, error = result.Error }); + } + finally + { + // Discard anything still staged (e.g. a validation call threw) so no unvalidated entry + // lingers in the staging overlay. + foreach (var token in pending) + await registry.DiscardStagedAsync(token).ConfigureAwait(false); } return Results.Json(new { ok = allOk, results }); @@ -226,26 +344,9 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi }); app.MapGet("/match-id-pattern", (string candidate, string pattern) => - { - try - { - if (candidate.Contains('*', StringComparison.Ordinal)) - { - if (!GtsId.TryParsePattern(candidate, out var cPat) || cPat is null || - !GtsId.TryParsePattern(pattern, out var pPat) || pPat is null) - return Results.Json(new { candidate, pattern, match = false, error = "invalid pattern" }); - return Results.Json(new { candidate, pattern, match = cPat.Matches(pPat) }); - } - - if (!GtsId.TryParse(candidate, out var cId) || cId is null) - return Results.Json(new { candidate, pattern, match = false, error = "invalid candidate" }); - return Results.Json(new { candidate, pattern, match = cId.Matches(pattern) }); - } - catch (Exception ex) - { - return Results.Json(new { candidate, pattern, match = false, error = ex.Message }); - } - }); + GtsId.TryMatch(candidate, pattern, out var match) + ? Results.Json(new { candidate, pattern, match }) + : Results.Json(new { candidate, pattern, match = false, error = "Invalid candidate or pattern" })); app.MapGet("/uuid", (string gts_id) => { @@ -263,14 +364,17 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi !jv.TryGetValue(out var instanceId)) return Results.Json(new { id = "", ok = false, error = "instance_id required" }); - var r = await registry.ValidateInstanceAsync(instanceId).ConfigureAwait(false); + var r = await registry.ValidateInstanceAsync(instanceId, refValidationMode: NormalizeRefValidationMode(req.Query["gts-ref-validation"])).ConfigureAwait(false); if (r.Ok) return Results.Json(new { id = instanceId, ok = true }); - return Results.Json(new { id = instanceId, ok = false, error = r.FailureReason ?? "validation failed" }); + return Results.Json(new { id = instanceId, ok = false, error = InstanceError(r) ?? "validation failed" }); }); async Task ValidateTypeSchema(HttpRequest req) { + var requestedMode = req.Query["gts-ref-validation"].ToString(); + if (!GtsRefValidationModes.TryParse(requestedMode, out _)) + return Results.Json(new { ok = false, error = "Invalid gts-ref-validation mode" }, statusCode: 422); var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); if (node is not JsonObject body) return Results.Json(new { id = "", ok = false, error = "type_id required" }); @@ -281,12 +385,12 @@ async Task ValidateTypeSchema(HttpRequest req) if (!GtsId.TryParse(typeId, out var gid) || gid is null || !gid.IsType) return Results.Json(new { id = typeId, ok = false, error = "Invalid GTS Type Schema ID" }); - var validation = await registry.ValidateSchemaAsync(gid).ConfigureAwait(false); + var validation = await registry.ValidateSchemaAsync(gid, refValidationMode: NormalizeRefValidationMode(req.Query["gts-ref-validation"])).ConfigureAwait(false); if (!validation.Ok) { var detail = validation.Errors is { Count: > 0 } ? string.Join("; ", validation.Errors) - : (validation.FailureReason ?? "validation failed"); + : (validation.FailureReason?.ToWire() ?? "validation failed"); return Results.Json(new { id = typeId, ok = false, error = detail }); } @@ -307,21 +411,21 @@ async Task ValidateTypeSchema(HttpRequest req) if (entityId.EndsWith("~", StringComparison.Ordinal)) return await ValidateSchemaBody(entityId).ConfigureAwait(false); - var r = await registry.ValidateInstanceAsync(entityId).ConfigureAwait(false); + var r = await registry.ValidateInstanceAsync(entityId, refValidationMode: NormalizeRefValidationMode(req.Query["gts-ref-validation"])).ConfigureAwait(false); return r.Ok ? Results.Json(new { id = entityId, ok = true, entity_type = "instance" }) - : Results.Json(new { id = entityId, ok = false, entity_type = "instance", error = r.FailureReason ?? "validation failed" }); + : Results.Json(new { id = entityId, ok = false, entity_type = "instance", error = InstanceError(r) ?? "validation failed" }); async Task ValidateSchemaBody(string sid) { if (!GtsId.TryParse(sid, out var gid) || gid is null) return Results.Json(new { id = sid, ok = false, entity_type = "schema", error = "Invalid id" }); - var vr = await registry.ValidateSchemaAsync(gid).ConfigureAwait(false); + var vr = await registry.ValidateSchemaAsync(gid, refValidationMode: NormalizeRefValidationMode(req.Query["gts-ref-validation"])).ConfigureAwait(false); if (!vr.Ok) { var detail = vr.Errors is { Count: > 0 } ? string.Join("; ", vr.Errors) - : (vr.FailureReason ?? "validation failed"); + : (vr.FailureReason?.ToWire() ?? "validation failed"); return Results.Json(new { id = sid, ok = false, entity_type = "schema", error = detail }); } @@ -362,8 +466,10 @@ async Task ValidateSchemaBody(string sid) return Results.Json(new { detail = new[] { new { loc = new[] { "body" }, msg = "Request body must be a JSON object", type = "type_error.object" } } }, statusCode: 422); var typeId = Uri.UnescapeDataString(gtsType); - if (!GtsId.TryParse(typeId, out var schemaId) || schemaId is null || !schemaId.IsType) + if (!GtsId.TryParse(typeId, out var schemaId) || schemaId is null) return Results.Json(new { ok = false, id = (string?)null, type_id = typeId, is_type_schema = false, error = $"Invalid GTS Type Schema ID: '{typeId}'" }); + if (!schemaId.IsType) + return Results.Json(new { ok = false, id = (string?)null, type_id = typeId, is_type_schema = false, error = "Registered entity must be GTS Type schema" }); var entity = GtsJsonEntity.ExtractEntity(body, HttpExtractOptions); var id = entity.GtsId?.Id; @@ -382,166 +488,11 @@ async Task ValidateSchemaBody(string sid) return Results.Json(graph); }); - app.MapGet("/compatibility", async (string old_schema_id, string new_schema_id) => - { - if (!GtsId.TryParse(old_schema_id, out var o) || o is null || !GtsId.TryParse(new_schema_id, out var n) || n is null) - return Results.Json(new - { - old = old_schema_id, - @new = new_schema_id, - is_backward_compatible = false, - is_forward_compatible = false, - is_fully_compatible = false, - backward_errors = new[] { "Invalid id" }, - forward_errors = new[] { "Invalid id" } - }); - - var a = await registry.GetAsync(o).ConfigureAwait(false); - var b = await registry.GetAsync(n).ConfigureAwait(false); - if (a is null || b is null || !a.IsSchema || !b.IsSchema) - return Results.Json(new - { - old = old_schema_id, - @new = new_schema_id, - is_backward_compatible = false, - is_forward_compatible = false, - is_fully_compatible = false, - backward_errors = new[] { "Schema not found" }, - forward_errors = new[] { "Schema not found" } - }); - - var oldFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(a.Content); - var newFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(b.Content); - var (backOk, backErr) = GtsJsonSchemaEvolutionCompatibility.CheckBackward(oldFlat, newFlat); - var (fwdOk, fwdErr) = GtsJsonSchemaEvolutionCompatibility.CheckForward(oldFlat, newFlat); - - return Results.Json(new - { - old = old_schema_id, - @new = new_schema_id, - is_backward_compatible = backOk, - is_forward_compatible = fwdOk, - is_fully_compatible = backOk && fwdOk, - backward_errors = backErr, - forward_errors = fwdErr - }); - }); - - app.MapPost("/cast", async (HttpRequest req) => - { - var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); - if (node is not JsonObject body) - return Results.Json(new { error = "instance_id required" }); - if (!body.TryGetPropertyValue("instance_id", out var i) || i is not JsonValue iv || !iv.TryGetValue(out var instanceId)) - return Results.Json(new { error = "instance_id required" }); - if (!body.TryGetPropertyValue("to_schema_id", out var t) || t is not JsonValue tv || !tv.TryGetValue(out var toSchemaId)) - return Results.Json(new { error = "to_schema_id required" }); - - if (!GtsId.TryParse(toSchemaId, out var toGid) || toGid is null || !toGid.IsType) - return Results.Json(new { error = "Invalid target schema id" }); - - var result = await registry.CastInstanceAsync(instanceId, toGid).ConfigureAwait(false); - if (!result.Ok) - { - return Results.Json(new - { - error = result.FailureReason, - instance_id = result.InstanceId, - from_schema_id = result.FromSchemaId?.Id, - to_schema_id = result.ToSchemaId?.Id, - schema_validation_errors = result.SchemaValidationErrors, - casted_entity = result.CastedContent is null ? null : JsonNode.Parse(result.CastedContent.ToJsonString()), - are_minor_variant_pair = result.Comparison?.AreMinorVariantPair, - is_structurally_compatible = result.Comparison?.IsStructurallyCompatible, - is_backward_compatible = result.Comparison?.IsBackwardEvolutionCompatible, - is_forward_compatible = result.Comparison?.IsForwardEvolutionCompatible - }); - } - - return Results.Json(new - { - casted_entity = JsonNode.Parse(result.CastedContent!.ToJsonString()), - is_backward_compatible = result.Comparison!.IsBackwardEvolutionCompatible, - is_forward_compatible = result.Comparison.IsForwardEvolutionCompatible, - is_structurally_compatible = result.Comparison.IsStructurallyCompatible - }); - }); - - app.MapGet("/query", async (string expr, int? limit) => - { - var lim = limit is >= 1 and <= 1000 ? limit!.Value : 100; - var result = await GtsQueryEngine.ExecuteAsync(registry, expr, lim).ConfigureAwait(false); - if (result.Error is not null) - return Results.Json(new { error = result.Error, results = Array.Empty(), limit = lim }); - return Results.Json(new { results = result.Results, count = result.Results.Count, limit = lim }); - }); + GtsOperationEndpoints.Map(app, registry); app.MapGet("/openapi", (HttpRequest req) => Results.Json(GtsOpenApiSpec.Build(req.Host.Host, req.Host.Port ?? (req.IsHttps ? 443 : 80)))); - app.MapGet("/attr", async (string gts_with_path) => - { - var r = await registry.GetAttributeAsync(gts_with_path).ConfigureAwait(false); - if (!r.Resolved) - { - return Results.Json(new - { - resolved = false, - error = r.Error, - available_fields = r.AvailableFields - }); - } - - var val = r.Value; - return val switch - { - JsonValue jv when jv.TryGetValue(out var s) => Results.Json(new { resolved = true, value = s }), - JsonValue jv when jv.TryGetValue(out var b) => Results.Json(new { resolved = true, value = b }), - JsonValue jv when jv.TryGetValue(out var ni) => Results.Json(new { resolved = true, value = ni }), - JsonValue jv when jv.TryGetValue(out var nd) => Results.Json(new { resolved = true, value = nd }), - JsonValue jv when jv.TryGetValue(out var nm) => Results.Json(new { resolved = true, value = nm }), - _ => Results.Json(new { resolved = true, value = val is null ? null : JsonNode.Parse(val.ToJsonString()) }) - }; - }); - return app; } - - private static bool TryGetString(JsonObject obj, string name, out string value) - { - value = ""; - if (!obj.TryGetPropertyValue(name, out var node) || node is not JsonValue jsonValue || - !jsonValue.TryGetValue(out var parsed) || string.IsNullOrEmpty(parsed)) - return false; - value = parsed; - return true; - } - - private static string? InstanceError(GtsInstanceValidationResult result) - { - if (result.Ok) - return null; - if (result.SchemaErrors is { Count: > 0 }) - { - var error = string.Join("; ", result.SchemaErrors); - return Regex.Replace(error, "Value is \\\"[^\\\"]+\\\" but should be \\\"([^\\\"]+)\\\"", "is not of type '$1'"); - } - return result.FailureReason switch - { - "SchemaNotFound" => "GTS Type Schema not found", - "NotASchema" => "Registered entity must be GTS Type schema", - _ => result.FailureReason ?? "Validation failed" - }; - } - - private static string? SchemaError(GtsSchemaValidationResult result) - { - if (result.Ok) - return null; - if (result.Errors is { Count: > 0 }) - return string.Join("; ", result.Errors); - return result.FailureReason == "PrecedentIncompatible" - ? "Parent GTS Type Schema not found" - : result.FailureReason ?? "JSON Schema validation failed"; - } } diff --git a/Gts.Application/GtsHttpApiHelpers.cs b/Gts.Application/GtsHttpApiHelpers.cs new file mode 100644 index 0000000..389b1d1 --- /dev/null +++ b/Gts.Application/GtsHttpApiHelpers.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Gts.Store; + +namespace Gts.Application; + +public static partial class GtsHttpApiExtensions +{ + internal static int EnumConstraintChange(JsonObject oldSchema, JsonObject newSchema) + { + var oldHasEnum = ContainsSchemaKeyword(oldSchema, "enum"); + var newHasEnum = ContainsSchemaKeyword(newSchema, "enum"); + if (oldHasEnum && !newHasEnum) return -1; + if (!oldHasEnum && newHasEnum) return 1; + return 0; + } + + internal static string NormalizeDialect(string? dialect) + { + if (dialect?.Contains("draft-07", StringComparison.Ordinal) == true) return "draft-07"; + if (dialect?.Contains("2019-09", StringComparison.Ordinal) == true) return "2019-09"; + if (dialect?.Contains("2020-12", StringComparison.Ordinal) == true) return "2020-12"; + return dialect?.TrimEnd('#') ?? "draft-07"; + } + + internal static bool ContainsSchemaKeyword(JsonNode? node, string keyword) => node switch + { + JsonObject obj => obj.ContainsKey(keyword) || obj.Any(property => ContainsSchemaKeyword(property.Value, keyword)), + JsonArray array => array.Any(item => ContainsSchemaKeyword(item, keyword)), + _ => false + }; + + internal static JsonObject ResolveSchemaRefs(JsonObject schema, IReadOnlyDictionary schemas, HashSet stack) + { + if (schema["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + { + var id = GtsConstants.StripUriPrefix(reference).Split('#')[0]; + if (schemas.TryGetValue(id, out var target) && stack.Add(id)) + { + var resolved = ResolveSchemaRefs(target, schemas, stack); + stack.Remove(id); + return resolved; + } + } + var clone = new JsonObject(); + foreach (var (key, value) in schema) + { + clone[key] = value switch + { + JsonObject child => ResolveSchemaRefs(child, schemas, stack), + JsonArray array => new JsonArray(array.Select(item => item is JsonObject child ? ResolveSchemaRefs(child, schemas, stack) : item?.DeepClone()).ToArray()), + _ => value?.DeepClone() + }; + } + return clone; + } + + internal static GtsRefValidationMode NormalizeRefValidationMode(string? value) => GtsRefValidationModes.Parse(value); + + internal static string? InstanceError(GtsInstanceValidationResult result) + { + if (result.Ok) return null; + if (result.SchemaErrors is { Count: > 0 }) + return Regex.Replace(string.Join("; ", result.SchemaErrors), "Value is \\\"[^\\\"]+\\\" but should be \\\"([^\\\"]+)\\\"", "is not of type '$1'"); + return result.FailureReason switch + { + GtsValidationFailure.SchemaNotFound => "GTS Type Schema not found", + GtsValidationFailure.NotASchema => "Registered entity must be GTS Type schema", + _ => result.FailureReason?.ToWire() ?? "Validation failed" + }; + } + + internal static string? SchemaError(GtsSchemaValidationResult result) + { + if (result.Ok) return null; + if (result.Errors is { Count: > 0 }) return string.Join("; ", result.Errors); + return result.FailureReason == GtsValidationFailure.PrecedentIncompatible ? "Parent GTS Type Schema not found" : result.FailureReason?.ToWire() ?? "JSON Schema validation failed"; + } +} \ No newline at end of file diff --git a/Gts.Application/GtsHttpContracts.cs b/Gts.Application/GtsHttpContracts.cs new file mode 100644 index 0000000..df9c210 --- /dev/null +++ b/Gts.Application/GtsHttpContracts.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Gts.Application; + +internal sealed record ValidateInstanceRequest(string InstanceId); +internal sealed record ValidateTypeSchemaRequest(string? TypeId, string? SchemaId); +internal sealed record ValidateEntityRequest(string? EntityId, string? GtsId); +internal sealed record CastRequest(string InstanceId, string? ToTypeId, string? ToSchemaId); +internal sealed record ValidateJsonResponse(bool Ok, string? Id, string? TypeId, bool IsTypeSchema, string? Error); +internal sealed record EntityRegistrationResponse(bool Ok, string? Id, string? TypeId, bool IsTypeSchema, string? Error = null); +internal sealed record CastResponse(JsonNode? CastedEntity, string BackwardCompatibility, string ForwardCompatibility, string FullCompatibility, string? Error = null); + +internal static class GtsHttpJson +{ + internal static JsonSerializerOptions Options { get; } = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; +} \ No newline at end of file diff --git a/Gts.Application/GtsOperationEndpoints.cs b/Gts.Application/GtsOperationEndpoints.cs new file mode 100644 index 0000000..935a8ba --- /dev/null +++ b/Gts.Application/GtsOperationEndpoints.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Gts; +using Gts.Store; + +namespace Gts.Application; + +internal static class GtsOperationEndpoints +{ + internal static void Map(WebApplication app, GtsRegistry registry) + { + app.MapGet("/compatibility", (string old_type_id, string new_type_id) => Compatibility(registry, old_type_id, new_type_id)); + app.MapPost("/cast", (HttpRequest request) => Cast(request, registry)); + app.MapGet("/query", async (string expr, int? limit) => + { + var normalizedLimit = limit is >= 1 and <= 1000 ? limit.Value : 100; + var result = await GtsQueryEngine.ExecuteAsync(registry, expr, normalizedLimit).ConfigureAwait(false); + return result.Error is null + ? Results.Json(new { results = result.Results, count = result.Results.Count, limit = normalizedLimit }) + : Results.Json(new { error = result.Error, results = Array.Empty(), limit = normalizedLimit }); + }); + app.MapGet("/attr", (string gts_with_path) => Attribute(registry, gts_with_path)); + } + + private static async Task Compatibility(GtsRegistry registry, string oldTypeId, string newTypeId) + { + if (!GtsId.TryParse(oldTypeId, out var oldId) || oldId is null || !GtsId.TryParse(newTypeId, out var newId) || newId is null) + return Results.Json(new { old = oldTypeId, @new = newTypeId, is_backward_compatible = false, is_forward_compatible = false, is_fully_compatible = false, backward_errors = new[] { "Invalid id" }, forward_errors = new[] { "Invalid id" } }); + var oldEntity = await registry.GetAsync(oldId).ConfigureAwait(false); + var newEntity = await registry.GetAsync(newId).ConfigureAwait(false); + if (oldEntity is null || newEntity is null || !oldEntity.IsSchema || !newEntity.IsSchema) + return Results.Json(new { old = oldTypeId, @new = newTypeId, is_backward_compatible = false, is_forward_compatible = false, is_fully_compatible = false, backward_errors = new[] { "Schema not found" }, forward_errors = new[] { "Schema not found" } }); + + var oldDialect = GtsHttpApiExtensions.NormalizeDialect(oldEntity.Content["$schema"]?.GetValue()); + var newDialect = GtsHttpApiExtensions.NormalizeDialect(newEntity.Content["$schema"]?.GetValue()); + if (oldDialect != newDialect || GtsHttpApiExtensions.ContainsSchemaKeyword(oldEntity.Content, "if") || GtsHttpApiExtensions.ContainsSchemaKeyword(newEntity.Content, "if")) + return Results.Json(new { old = oldTypeId, @new = newTypeId, backward_compatibility = "unknown", forward_compatibility = "unknown", full_compatibility = "unknown" }); + + var registered = await registry.GetAllAsync().ConfigureAwait(false); + var schemas = registered.Where(entity => entity.IsSchema && entity.GtsId is not null) + .ToDictionary(entity => entity.GtsId!.Id, entity => entity.Content, StringComparer.Ordinal); + var oldFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsHttpApiExtensions.ResolveSchemaRefs(oldEntity.Content, schemas, new HashSet())); + var newFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsHttpApiExtensions.ResolveSchemaRefs(newEntity.Content, schemas, new HashSet())); + var (backward, backwardErrors, forward, forwardErrors) = GtsSchemaCompatibilityService.CompareEvolution(oldFlat, newFlat); + return Results.Json(new + { + old = oldTypeId, + @new = newTypeId, + backward_compatibility = backward ? "compatible" : "incompatible", + forward_compatibility = forward ? "compatible" : "incompatible", + full_compatibility = backward && forward ? "compatible" : "incompatible", + is_backward_compatible = backward, + is_forward_compatible = forward, + is_fully_compatible = backward && forward, + backward_errors = backwardErrors, + forward_errors = forwardErrors + }); + } + + private static async Task Cast(HttpRequest request, GtsRegistry registry) + { + var body = await JsonSerializer.DeserializeAsync(request.Body, GtsHttpJson.Options).ConfigureAwait(false); + if (string.IsNullOrEmpty(body?.InstanceId)) + return Results.Json(new { error = "instance_id required" }); + var instanceId = body.InstanceId; + var targetId = body.ToTypeId ?? body.ToSchemaId; + if (string.IsNullOrEmpty(targetId)) + return Results.Json(new { error = "to_type_id required" }); + if (!GtsId.TryParse(targetId, out var targetGtsId) || targetGtsId is null || !targetGtsId.IsType) + return Results.Json(new { error = "Invalid target schema id" }); + + var source = await registry.GetByInstanceIdAsync(instanceId).ConfigureAwait(false); + var target = await registry.GetAsync(targetGtsId).ConfigureAwait(false); + if (source is not null && target is not null && GtsId.TryParse(source.SchemaId, out var sourceType) && sourceType is not null) + { + var sourceSchema = await registry.GetAsync(sourceType).ConfigureAwait(false); + if (sourceSchema is not null && GtsHttpApiExtensions.NormalizeDialect(sourceSchema.Content["$schema"]?.GetValue()) != GtsHttpApiExtensions.NormalizeDialect(target.Content["$schema"]?.GetValue())) + return Results.Json(new { casted_entity = source.Content.DeepClone(), backward_compatibility = "unknown", forward_compatibility = "unknown", full_compatibility = "unknown" }); + } + + var result = await registry.CastInstanceAsync(instanceId, targetGtsId).ConfigureAwait(false); + if (!result.Ok) + return Results.Json(new + { + error = result.FailureReason == GtsValidationFailure.NotAnInstance ? "Source entity must be an instance" : result.FailureReason?.ToWire(), + instance_id = result.InstanceId, + from_schema_id = result.FromSchemaId?.Id, + to_schema_id = result.ToSchemaId?.Id, + schema_validation_errors = result.SchemaValidationErrors, + casted_entity = result.CastedContent?.DeepClone(), + are_minor_variant_pair = result.Comparison?.AreMinorVariantPair, + is_structurally_compatible = result.Comparison?.IsStructurallyCompatible, + is_backward_compatible = result.Comparison?.IsBackwardEvolutionCompatible, + is_forward_compatible = result.Comparison?.IsForwardEvolutionCompatible + }); + + var fromSchema = result.FromSchemaId is null ? null : await registry.GetAsync(result.FromSchemaId).ConfigureAwait(false); + var toSchema = result.ToSchemaId is null ? null : await registry.GetAsync(result.ToSchemaId).ConfigureAwait(false); + var distinctDialects = fromSchema is not null && toSchema is not null && GtsHttpApiExtensions.NormalizeDialect(fromSchema.Content["$schema"]?.GetValue()) != GtsHttpApiExtensions.NormalizeDialect(toSchema.Content["$schema"]?.GetValue()); + var comparison = result.Comparison!; + var enumChange = fromSchema is not null && toSchema is not null ? GtsHttpApiExtensions.EnumConstraintChange(fromSchema.Content, toSchema.Content) : 0; + var backward = distinctDialects ? "unknown" : enumChange < 0 ? "compatible" : enumChange > 0 ? "incompatible" : comparison.IsBackwardEvolutionCompatible ? "compatible" : "incompatible"; + var forward = distinctDialects ? "unknown" : enumChange < 0 ? "incompatible" : enumChange > 0 ? "compatible" : comparison.IsForwardEvolutionCompatible ? "compatible" : "incompatible"; + return Results.Json(new + { + casted_entity = result.CastedContent!.DeepClone(), + backward_compatibility = backward, + forward_compatibility = forward, + full_compatibility = distinctDialects ? "unknown" : backward == "compatible" && forward == "compatible" ? "compatible" : "incompatible", + is_backward_compatible = comparison.IsBackwardEvolutionCompatible, + is_forward_compatible = comparison.IsForwardEvolutionCompatible, + is_structurally_compatible = comparison.IsStructurallyCompatible + }); + } + + private static async Task Attribute(GtsRegistry registry, string selector) + { + var result = await registry.GetAttributeAsync(selector).ConfigureAwait(false); + if (!result.Resolved) + return Results.Json(new { resolved = false, error = result.Error, available_fields = result.AvailableFields }); + return result.Value switch + { + JsonValue value when value.TryGetValue(out var text) => Results.Json(new { resolved = true, value = text }), + JsonValue value when value.TryGetValue(out var boolean) => Results.Json(new { resolved = true, value = boolean }), + JsonValue value when value.TryGetValue(out var integer) => Results.Json(new { resolved = true, value = integer }), + JsonValue value when value.TryGetValue(out var number) => Results.Json(new { resolved = true, value = number }), + JsonValue value when value.TryGetValue(out var decimalNumber) => Results.Json(new { resolved = true, value = decimalNumber }), + _ => Results.Json(new { resolved = true, value = result.Value?.DeepClone() }) + }; + } +} \ No newline at end of file diff --git a/Gts.Application/GtsRegistryBootstrap.cs b/Gts.Application/GtsRegistryBootstrap.cs index 08d0ce9..49dbeda 100644 --- a/Gts.Application/GtsRegistryBootstrap.cs +++ b/Gts.Application/GtsRegistryBootstrap.cs @@ -147,20 +147,20 @@ public static async Task LoadIntoRegistryAsync( { var clone = (JsonObject)jo.DeepClone()!; GtsJsonKeyNormalizer.Apply(clone); - await GtsEntityOperations.TryAddAsync(registry, clone, validate: false, opt, cancellationToken) + await GtsEntityOperations.TryAddAsync(registry, clone, validate: false, opt, cancellationToken: cancellationToken) .ConfigureAwait(false); } } break; case JsonObject obj: - { - var clone = (JsonObject)obj.DeepClone()!; - GtsJsonKeyNormalizer.Apply(clone); - await GtsEntityOperations.TryAddAsync(registry, clone, validate: false, opt, cancellationToken) - .ConfigureAwait(false); - break; - } + { + var clone = (JsonObject)obj.DeepClone()!; + GtsJsonKeyNormalizer.Apply(clone); + await GtsEntityOperations.TryAddAsync(registry, clone, validate: false, opt, cancellationToken: cancellationToken) + .ConfigureAwait(false); + break; + } } } } diff --git a/Gts.Cli/Gts.Cli.csproj b/Gts.Cli/Gts.Cli.csproj index 696717e..4da41b3 100644 --- a/Gts.Cli/Gts.Cli.csproj +++ b/Gts.Cli/Gts.Cli.csproj @@ -2,9 +2,6 @@ Exe - net8.0 - enable - enable Gts.Cli diff --git a/Gts.Store/Gts.Store.csproj b/Gts.Store/Gts.Store.csproj index 54b0e1b..a5080ca 100644 --- a/Gts.Store/Gts.Store.csproj +++ b/Gts.Store/Gts.Store.csproj @@ -1,17 +1,12 @@  - - net8.0 - enable - enable - - - + + diff --git a/Gts.Store/GtsCastService.cs b/Gts.Store/GtsCastService.cs new file mode 100644 index 0000000..b22a8d5 --- /dev/null +++ b/Gts.Store/GtsCastService.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Gts.Extraction; +using Gts.Store.Validation; +using Json.Schema; + +namespace Gts.Store; + +internal sealed class GtsCastService(IGtsStore store) +{ + internal async ValueTask CastAsync(string instanceId, GtsId targetSchemaId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(instanceId)) return Failure(instanceId, targetSchemaId, GtsValidationFailure.InvalidInstanceId); + ArgumentNullException.ThrowIfNull(targetSchemaId); + cancellationToken.ThrowIfCancellationRequested(); + var id = instanceId.Trim(); + var entity = await store.GetByInstanceIdAsync(id).ConfigureAwait(false); + if (entity is null) return Failure(id, targetSchemaId, GtsValidationFailure.InstanceNotFound); + if (entity.IsSchema) return Failure(id, targetSchemaId, GtsValidationFailure.NotAnInstance); + if (!targetSchemaId.IsType) return Failure(id, targetSchemaId, GtsValidationFailure.InvalidTargetSchemaId); + + var targetSchema = await store.GetAsync(targetSchemaId).ConfigureAwait(false); + if (targetSchema is null || !targetSchema.IsSchema) return Failure(id, targetSchemaId, GtsValidationFailure.TargetSchemaNotFound); + var extracted = GtsJsonEntity.ExtractId(entity.Content); + if (string.IsNullOrEmpty(extracted.SchemaId) || !GtsId.TryParse(extracted.SchemaId, out var sourceSchemaId) || sourceSchemaId is null || !sourceSchemaId.IsType) + return Failure(id, targetSchemaId, GtsValidationFailure.SchemaIdMissing); + var sourceSchema = await store.GetAsync(sourceSchemaId).ConfigureAwait(false); + if (sourceSchema is null || !sourceSchema.IsSchema) + return new GtsInstanceCastResult { Ok = false, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, FailureReason = GtsValidationFailure.SourceSchemaNotFound }; + + var sourceDocument = CanonicalSchema(sourceSchema.Content); + var targetDocument = CanonicalSchema(targetSchema.Content); + var comparison = GtsSchemaMinorVersionCompatibility.ComparePair(sourceSchemaId, sourceDocument, targetSchemaId, targetDocument); + if (!comparison.AreMinorVariantPair) + return new GtsInstanceCastResult { Ok = false, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, FailureReason = GtsValidationFailure.NotMinorVariantPair, Comparison = comparison }; + + var entities = await store.SnapshotForReadAsync().ConfigureAwait(false); + var schemaById = new Dictionary(StringComparer.Ordinal); + foreach (var candidate in entities) + { + if (candidate.IsSchema && candidate.GtsId is not null) + schemaById[candidate.GtsId.Id] = candidate; + } + + JsonObject? Load(GtsId schemaId) + { + return schemaById.TryGetValue(schemaId.Id, out var stored) && stored.IsSchema + ? CanonicalSchema(stored.Content) + : null; + } + var targetEffective = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(GtsSchemaDependencyGraph.Resolve(targetDocument, Load)); + var casted = GtsInstanceCast.CastToEffectiveSchema(entity.Content, targetEffective); + var schemas = entities.Where(candidate => candidate.IsSchema && candidate.GtsId is not null) + .ToDictionary(candidate => candidate.GtsId!, candidate => GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(candidate.Content)); + if (!schemas.ContainsKey(targetSchemaId)) + return new GtsInstanceCastResult { Ok = false, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, FailureReason = GtsValidationFailure.SchemaNormalizationFailed, Comparison = comparison, CastedContent = casted }; + + var tolerant = (JsonObject)GtsInstanceCast.RemoveGtsConstConstraints(targetDocument.DeepClone())!.AsObject(); + schemas[targetSchemaId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(tolerant); + EvaluationResults evaluation; + try + { + evaluation = GtsJsonSchemaEvaluator.Evaluate(casted, targetSchemaId, schemas); + } + catch (RegexMatchTimeoutException) + { + // A schema `pattern` whose match exceeds the engine's bounded time + // budget fails the cast rather than propagating; see the matching + // guard in GtsInstanceValidationService. + return new GtsInstanceCastResult { Ok = false, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, FailureReason = GtsValidationFailure.CastValidationFailed, Comparison = comparison, CastedContent = casted, SchemaValidationErrors = new[] { "regular expression match timed out" } }; + } + if (!evaluation.IsValid) + return new GtsInstanceCastResult { Ok = false, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, FailureReason = GtsValidationFailure.CastValidationFailed, Comparison = comparison, CastedContent = casted, SchemaValidationErrors = GtsJsonSchemaEvaluator.FlattenErrors(evaluation) }; + return new GtsInstanceCastResult { Ok = true, InstanceId = id, FromSchemaId = sourceSchemaId, ToSchemaId = targetSchemaId, CastedContent = casted, Comparison = comparison }; + } + + private static JsonObject CanonicalSchema(JsonObject schema) => + !schema.ContainsKey("$schema") && schema.ContainsKey("$$schema") + ? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(schema) + : schema; + + private static GtsInstanceCastResult Failure(string? id, GtsId target, GtsValidationFailure reason) => new() { Ok = false, InstanceId = id, ToSchemaId = target, FailureReason = reason }; +} \ No newline at end of file diff --git a/Gts.Store/GtsFormatValidator.cs b/Gts.Store/GtsFormatValidator.cs new file mode 100644 index 0000000..0b0dc91 --- /dev/null +++ b/Gts.Store/GtsFormatValidator.cs @@ -0,0 +1 @@ +namespace Gts.Store; diff --git a/Gts.Store/GtsInstanceCast.cs b/Gts.Store/GtsInstanceCast.cs index dc7f038..8abb4b7 100644 --- a/Gts.Store/GtsInstanceCast.cs +++ b/Gts.Store/GtsInstanceCast.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using System.Text.Json.Nodes; namespace Gts.Store; @@ -42,7 +41,7 @@ private static void CastObject(JsonObject result, JsonObject schema, string base if (targetProps.TryGetPropertyValue(prop, out var pSchema) && pSchema is JsonObject pso && pso.TryGetPropertyValue("default", out var def)) { - result[prop] = def.DeepClone(); + result[prop] = def?.DeepClone(); } } @@ -53,7 +52,7 @@ private static void CastObject(JsonObject result, JsonObject schema, string base if (result.ContainsKey(prop)) continue; if (pSchema is JsonObject pso && pso.TryGetPropertyValue("default", out var def)) - result[prop] = def.DeepClone(); + result[prop] = def?.DeepClone(); } foreach (var (prop, pSchema) in targetProps) @@ -150,6 +149,4 @@ public static JsonNode RemoveGtsConstConstraints(JsonNode? node) } } - internal static JsonDocument ToJsonDocument(JsonObject o) - => JsonDocument.Parse(o.ToJsonString()); } diff --git a/Gts.Store/GtsInstanceCastResult.cs b/Gts.Store/GtsInstanceCastResult.cs index bcc950a..f546f10 100644 --- a/Gts.Store/GtsInstanceCastResult.cs +++ b/Gts.Store/GtsInstanceCastResult.cs @@ -20,7 +20,7 @@ public sealed class GtsInstanceCastResult public GtsId? ToSchemaId { get; init; } /// High-level failure code when is false; null on success. - public string? FailureReason { get; init; } + public GtsValidationFailure? FailureReason { get; init; } /// Deep-cloned instance updated toward the target effective schema; null when is false. public JsonObject? CastedContent { get; init; } diff --git a/Gts.Store/GtsInstanceValidationResult.cs b/Gts.Store/GtsInstanceValidationResult.cs index 711bef6..68664c5 100644 --- a/Gts.Store/GtsInstanceValidationResult.cs +++ b/Gts.Store/GtsInstanceValidationResult.cs @@ -12,7 +12,7 @@ public sealed class GtsInstanceValidationResult public string? Id { get; init; } /// High-level failure code when is false; null on success. - public string? FailureReason { get; init; } + public GtsValidationFailure? FailureReason { get; init; } /// Flattened JSON Schema validation messages when is SchemaValidationFailed. public IReadOnlyList? SchemaErrors { get; init; } diff --git a/Gts.Store/GtsInstanceValidationService.cs b/Gts.Store/GtsInstanceValidationService.cs new file mode 100644 index 0000000..5914469 --- /dev/null +++ b/Gts.Store/GtsInstanceValidationService.cs @@ -0,0 +1,132 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Gts.Extraction; +using Gts.Store.Validation; + +namespace Gts.Store; + +internal sealed class GtsInstanceValidationService( + IGtsStore store, + Func> validateSchema) +{ + internal async ValueTask ValidateStoredAsync( + string instanceId, + CancellationToken cancellationToken, + GtsRefValidationMode refValidationMode) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(instanceId)) + return Failure(instanceId, null); + var id = instanceId.Trim(); + var entity = await store.GetByInstanceIdAsync(id).ConfigureAwait(false); + if (entity is null) return Failure(id, GtsValidationFailure.InstanceNotFound); + if (entity.IsSchema) return Failure(id, GtsValidationFailure.NotAnInstance); + var extracted = GtsJsonEntity.ExtractId(entity.Content); + if (string.IsNullOrEmpty(extracted.SchemaId) || !extracted.SchemaId.EndsWith('~')) return Failure(id, GtsValidationFailure.SchemaIdMissing); + if (!GtsId.TryParse(extracted.SchemaId, out var schemaId) || schemaId is null || !schemaId.IsType) return Failure(id, GtsValidationFailure.InvalidSchemaId); + return await ValidateAsync(entity.Content, schemaId, id, cancellationToken, refValidationMode).ConfigureAwait(false); + } + + internal async ValueTask ValidateAsync( + JsonObject content, + GtsId schemaId, + string? instanceId, + CancellationToken cancellationToken, + GtsRefValidationMode refValidationMode) + { + cancellationToken.ThrowIfCancellationRequested(); + var schemaEntity = await store.GetAsync(schemaId).ConfigureAwait(false); + if (schemaEntity is null) return Failure(instanceId, GtsValidationFailure.SchemaNotFound); + if (!schemaEntity.IsSchema) return Failure(instanceId, GtsValidationFailure.NotASchema); + var schemaDocument = !schemaEntity.Content.ContainsKey("$schema") && schemaEntity.Content.ContainsKey("$$schema") + ? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(schemaEntity.Content) + : schemaEntity.Content; + if (schemaDocument[GtsSchemaKeywords.Abstract] is JsonValue abstractValue && abstractValue.TryGetValue(out var abstractType) && abstractType) + return Failure(instanceId, GtsValidationFailure.AbstractTypeNotInstantiable); + + var schemaValidation = await validateSchema(schemaId, cancellationToken, refValidationMode).ConfigureAwait(false); + if (!schemaValidation.Ok) + return new GtsInstanceValidationResult { Ok = false, Id = instanceId, FailureReason = GtsValidationFailure.TypeSchemaValidationFailed, SchemaErrors = schemaValidation.Errors }; + + var entities = await store.SnapshotForReadAsync().ConfigureAwait(false); + if (HasMixedDialectReferences(schemaDocument, entities)) return Failure(instanceId, GtsValidationFailure.MixedDialectSchemaGraph); + + // Build id -> schema-entity index and the evaluation-normalized schema map in a single pass, so + // schema resolution during validation is O(1) per lookup instead of an O(N) linear scan per $ref. + var schemaEntityById = new Dictionary(StringComparer.Ordinal); + var schemas = new Dictionary(); + foreach (var entity in entities) + { + if (!entity.IsSchema || entity.GtsId is null) + continue; + schemaEntityById[entity.GtsId.Id] = entity; + schemas[entity.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(entity.Content); + } + if (!schemas.ContainsKey(schemaId)) return Failure(instanceId, GtsValidationFailure.SchemaNotFound); + + JsonObject? Load(GtsId id) + { + if (!schemaEntityById.TryGetValue(id.Id, out var entity)) + return null; + var loaded = entity.Content; + return !loaded.ContainsKey("$schema") && loaded.ContainsKey("$$schema") + ? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(loaded) + : loaded; + } + var effectiveSchema = GtsSchemaDependencyGraph.Resolve(schemaDocument, Load); + try + { + var evaluation = GtsJsonSchemaEvaluator.Evaluate(content, schemaId, schemas); + var referenceErrors = GtsRefValidator.ValidateInstance(content, effectiveSchema, schemaId.Id, entities, refValidationMode); + if (evaluation.IsValid && referenceErrors.Count == 0) + return new GtsInstanceValidationResult { Ok = true, Id = instanceId }; + return new GtsInstanceValidationResult + { + Ok = false, + Id = instanceId, + FailureReason = referenceErrors.Count > 0 ? GtsValidationFailure.GtsRefValidationFailed : GtsValidationFailure.SchemaValidationFailed, + SchemaErrors = referenceErrors.Count > 0 ? referenceErrors : GtsJsonSchemaEvaluator.FlattenErrors(evaluation) + }; + } + catch (RegexMatchTimeoutException) + { + // An untrusted schema `pattern` whose match exceeds the engine's + // bounded time budget is reported as a validation failure rather + // than propagating a hang/exception. The JsonSchema.Net engine + // bounds match time; this mirrors the "regular expression match + // timed out" outcome the sibling runtimes surface (gts-go/gts-python). + return new GtsInstanceValidationResult + { + Ok = false, + Id = instanceId, + FailureReason = GtsValidationFailure.SchemaValidationFailed, + SchemaErrors = new[] { "regular expression match timed out" } + }; + } + } + + private static GtsInstanceValidationResult Failure(string? id, GtsValidationFailure? reason) => new() { Ok = false, Id = id, FailureReason = reason }; + + private static bool HasMixedDialectReferences(JsonObject schema, IEnumerable entities) + { + var dialect = GtsSchemaDependencyGraph.Dialect(schema); + var schemas = entities.Where(entity => entity.IsSchema && entity.GtsId is not null) + .ToDictionary(entity => entity.GtsId!.Id, entity => entity.Content, StringComparer.Ordinal); + return HasMixedDialectReferences(schema, dialect, schemas, new HashSet()); + } + + private static bool HasMixedDialectReferences(JsonNode? node, string dialect, IReadOnlyDictionary schemas, HashSet visited) + { + if (node is JsonObject obj) + { + if (obj["$ref"] is JsonValue value && value.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + { + var id = GtsConstants.StripUriPrefix(reference).Split('#')[0]; + if (schemas.TryGetValue(id, out var target) && visited.Add(id) && GtsSchemaDependencyGraph.Dialect(target) != dialect) + return true; + } + return obj.Any(property => HasMixedDialectReferences(property.Value, dialect, schemas, visited)); + } + return node is JsonArray array && array.Any(child => HasMixedDialectReferences(child, dialect, schemas, visited)); + } +} \ No newline at end of file diff --git a/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs b/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs index 83d8bfe..0e8015f 100644 --- a/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs +++ b/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs @@ -42,6 +42,39 @@ private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( errors.Add($"Removed required properties: {string.Join(", ", removedRequired)}"); } + var oldClosed = IsClosed(oldFlat); + var newClosed = IsClosed(newFlat); + if (checkBackward) + { + if (!oldClosed) + { + foreach (var added in newProps.Keys.Except(oldProps.Keys)) + errors.Add($"Added property constraint '{added}'"); + } + if (!oldClosed && newClosed) + errors.Add("New schema closes an open object"); + if (newClosed) + { + foreach (var removed in oldProps.Keys.Except(newProps.Keys)) + errors.Add($"Removed property '{removed}' from a closed object"); + } + } + else + { + if (!newClosed) + { + foreach (var removed in oldProps.Keys.Except(newProps.Keys)) + errors.Add($"Removed property constraint '{removed}'"); + } + if (oldClosed && !newClosed) + errors.Add("New schema opens a closed object"); + if (oldClosed) + { + foreach (var added in newProps.Keys.Except(oldProps.Keys)) + errors.Add($"Added property '{added}' to a closed object"); + } + } + foreach (var prop in oldProps.Keys.Intersect(newProps.Keys)) { var oldPropSchema = AsObject(oldProps[prop]); @@ -52,7 +85,9 @@ private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( var oldType = GetTypeString(oldPropSchema); var newType = GetTypeString(newPropSchema); - if (oldType is not null && newType is not null && oldType != newType) + if (oldType is not null && newType is not null && oldType != newType && + !(checkBackward && oldType == "integer" && newType == "number") && + !(!checkBackward && oldType == "number" && newType == "integer")) errors.Add($"Property '{prop}' type changed from {oldType} to {newType}"); var oldEnum = oldPropSchema["enum"] as JsonArray; @@ -62,22 +97,36 @@ private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( var oldSet = EnumToStringSet(oldEnum); var newSet = EnumToStringSet(newEnum); if (checkBackward) - { - var added = newSet.Except(oldSet).ToHashSet(); - if (added.Count > 0) - errors.Add($"Property '{prop}' added enum values: {string.Join(", ", added)}"); - } - else { var removed = oldSet.Except(newSet).ToHashSet(); if (removed.Count > 0) errors.Add($"Property '{prop}' removed enum values: {string.Join(", ", removed)}"); } + else + { + var added = newSet.Except(oldSet).ToHashSet(); + if (added.Count > 0) + errors.Add($"Property '{prop}' added enum values: {string.Join(", ", added)}"); + } } + else if (checkBackward && oldEnum is null && newEnum is not null) + errors.Add($"Property '{prop}' added enum constraint"); + else if (!checkBackward && oldEnum is not null && newEnum is null) + errors.Add($"Property '{prop}' removed enum constraint"); + + var oldConst = oldPropSchema["const"]; + var newConst = newPropSchema["const"]; + if (oldConst is not null && newConst is not null && !JsonNode.DeepEquals(oldConst, newConst)) + errors.Add($"Property '{prop}' const changed"); + else if (checkBackward && oldConst is null && newConst is not null) + errors.Add($"Property '{prop}' added const constraint"); + else if (!checkBackward && oldConst is not null && newConst is null) + errors.Add($"Property '{prop}' removed const constraint"); errors.AddRange(CheckConstraintCompatibility(prop, oldPropSchema, newPropSchema, checkBackward)); - if (oldType == "object" && newType == "object") + if ((oldType == "object" && newType == "object") || + oldPropSchema["properties"] is JsonObject && newPropSchema["properties"] is JsonObject) { var (nestedOk, nestedErrs) = CheckSchemaCompatibility(oldPropSchema, newPropSchema, checkBackward); if (!nestedOk) @@ -87,7 +136,8 @@ private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( } } - if (oldType == "array" && newType == "array") + if ((oldType == "array" && newType == "array") || + oldPropSchema["items"] is not null && newPropSchema["items"] is not null) { var oi = oldPropSchema["items"] as JsonObject; var ni = newPropSchema["items"] as JsonObject; @@ -95,6 +145,10 @@ private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( { var oit = GetTypeString(oi); var nit = GetTypeString(ni); + if (oit is not null && nit is not null && oit != nit && + !(checkBackward && oit == "integer" && nit == "number") && + !(!checkBackward && oit == "number" && nit == "integer")) + errors.Add($"Property '{prop}' array item type changed from {oit} to {nit}"); if (oit == "object" && nit == "object") { var (nestedOk, nestedErrs) = CheckSchemaCompatibility(oi, ni, checkBackward); @@ -108,9 +162,28 @@ private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( } } + if (oldFlat["items"] is JsonObject oldItems && newFlat["items"] is JsonObject newItems) + { + var oldItemType = GetTypeString(oldItems); + var newItemType = GetTypeString(newItems); + if (oldItemType is not null && newItemType is not null && oldItemType != newItemType) + errors.Add($"Array item type changed from {oldItemType} to {newItemType}"); + errors.AddRange(CheckConstraintCompatibility("items", oldItems, newItems, checkBackward)); + } + return (errors.Count == 0, errors); } + private static bool IsClosed(JsonObject schema) + { + foreach (var keyword in new[] { "additionalProperties", "unevaluatedProperties" }) + { + if (schema[keyword] is JsonValue value && value.TryGetValue(out var allowed) && !allowed) + return true; + } + return false; + } + private static HashSet EnumToStringSet(JsonArray arr) { var set = new HashSet(StringComparer.Ordinal); @@ -151,7 +224,7 @@ private static IReadOnlyList CheckConstraintCompatibility( if (propType == "string") errors.AddRange(CheckMinMaxConstraint(prop, oldPropSchema, newPropSchema, "minLength", "maxLength", checkTightening)); - if (propType == "array") + if (propType == "array" || oldPropSchema["items"] is not null || oldPropSchema["minItems"] is not null || oldPropSchema["maxItems"] is not null) errors.AddRange(CheckMinMaxConstraint(prop, oldPropSchema, newPropSchema, "minItems", "maxItems", checkTightening)); return errors; @@ -273,7 +346,13 @@ public static JsonObject FlattenSchema(JsonObject schema) } if (schema.TryGetPropertyValue("additionalProperties", out var ap)) - result["additionalProperties"] = ap.DeepClone(); + result["additionalProperties"] = ap?.DeepClone(); + if (schema.TryGetPropertyValue("unevaluatedProperties", out var up)) + result["unevaluatedProperties"] = up?.DeepClone(); + if (schema.TryGetPropertyValue("type", out var type)) + result["type"] = type?.DeepClone(); + if (schema.TryGetPropertyValue("items", out var items)) + result["items"] = items?.DeepClone(); return result; } @@ -295,6 +374,6 @@ private static void MergeFlatInto(JsonObject target, JsonObject flattened) } if (flattened.TryGetPropertyValue("additionalProperties", out var ap)) - target["additionalProperties"] = ap.DeepClone(); + target["additionalProperties"] = ap?.DeepClone(); } } diff --git a/Gts.Store/GtsQuery.cs b/Gts.Store/GtsQuery.cs index eba6660..aa6e19d 100644 --- a/Gts.Store/GtsQuery.cs +++ b/Gts.Store/GtsQuery.cs @@ -61,7 +61,7 @@ public static bool TryParse( return false; } - if (!GtsId.TryParsePattern(basePart, out var w) || w is null) + if (basePart.Count(c => c == '*') != 1 || basePart.Length < 2 || basePart[^2] is not ('.' or '~')) { error = "Invalid query"; return false; @@ -139,7 +139,7 @@ public static List Execute( continue; if (!MatchFilters(e.Content, query.Filters)) continue; - results.Add((JsonObject)JsonNode.Parse(e.Content.ToJsonString())!); + results.Add((JsonObject)e.Content.DeepClone()); } return results; @@ -149,7 +149,10 @@ public static List Execute( internal static int NormalizeLimit(int limit) => limit is >= 1 and <= 1000 ? limit : 100; internal static bool IdMatches(GtsId id, GtsParsedQuery q) => - q.IsWildcard ? id.Matches(q.BasePattern) : string.Equals(id.Id, q.BasePattern, StringComparison.Ordinal); + q.IsWildcard + ? id.Matches(q.BasePattern) && (!q.BasePattern.EndsWith("~*", StringComparison.Ordinal) || + id.Segments.Count > q.BasePattern[..^1].Count(character => character == '~')) + : string.Equals(id.Id, q.BasePattern, StringComparison.Ordinal); private static Dictionary ParseFilters(string? filterPart) { diff --git a/Gts.Store/GtsRefConstraint.cs b/Gts.Store/GtsRefConstraint.cs new file mode 100644 index 0000000..2775dc9 --- /dev/null +++ b/Gts.Store/GtsRefConstraint.cs @@ -0,0 +1,33 @@ +namespace Gts.Store; + +internal readonly record struct GtsRefConstraint(string Pattern) +{ + internal static bool TryCreate(string operand, string selectedTypeId, out GtsRefConstraint constraint) + { + var pattern = operand == "/$id" ? selectedTypeId : GtsConstants.StripUriPrefix(operand); + if (operand.StartsWith('/') && operand != "/$id" || !pattern.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal)) + { + constraint = default; + return false; + } + var valid = pattern.Contains('*') + ? pattern.Count(character => character == '*') == 1 && pattern.EndsWith('*') + : GtsId.TryParse(pattern, out _); + constraint = new GtsRefConstraint(pattern); + return valid; + } + + internal bool Matches(string value) + { + if (Pattern == GtsConstants.IdPrefix + "*") return true; + if (Pattern.EndsWith('*')) + return value.StartsWith(Pattern[..^1], StringComparison.Ordinal); + if (!value.StartsWith(Pattern, StringComparison.Ordinal)) + return false; + // Prefix matching alone ignores segment boundaries: an exact constraint such as + // "gts.a.b.c.d.v1~x.y.z.w.v1" would otherwise also accept "…w.v12" or "…w.v1.5". + // Type patterns (ending with '~') admit derived identifiers; any other (exact) pattern + // requires a full match or a '~' segment boundary immediately after the pattern. + return value.Length == Pattern.Length || Pattern.EndsWith('~') || value[Pattern.Length] == '~'; + } +} \ No newline at end of file diff --git a/Gts.Store/GtsRefValidationMode.cs b/Gts.Store/GtsRefValidationMode.cs new file mode 100644 index 0000000..b6f6984 --- /dev/null +++ b/Gts.Store/GtsRefValidationMode.cs @@ -0,0 +1,63 @@ +namespace Gts.Store; + +/// +/// Controls how strictly GTS x-gts-ref constraints are checked during schema and instance validation. +/// +public enum GtsRefValidationMode +{ + /// Only check that referenced identifiers are syntactically valid; registration is not required. + None, + + /// Require each referenced identifier to resolve to a registered entity, without validating that entity. + AnyPresent, + + /// Require each reference to resolve to a registered entity that is itself valid against its schema. + AnyValid, +} + +/// +/// Parsing helpers mapping the wire tokens (none, any-present, any-valid) used by the HTTP API +/// and query string to . +/// +public static class GtsRefValidationModes +{ + /// Mode used when a caller does not specify one. + public const GtsRefValidationMode Default = GtsRefValidationMode.AnyValid; + + /// + /// Attempts to parse a wire token. Null/empty input maps to and returns true; + /// an unrecognized non-empty token returns false (with set to ). + /// + public static bool TryParse(string? value, out GtsRefValidationMode mode) + { + switch (value?.Trim()) + { + case null or "": + mode = Default; + return true; + case "none": + mode = GtsRefValidationMode.None; + return true; + case "any-present": + mode = GtsRefValidationMode.AnyPresent; + return true; + case "any-valid": + mode = GtsRefValidationMode.AnyValid; + return true; + default: + mode = Default; + return false; + } + } + + /// Parses a wire token, falling back to for empty or unrecognized input. + public static GtsRefValidationMode Parse(string? value) => TryParse(value, out var mode) ? mode : Default; + + /// Returns the canonical wire token for a mode. + public static string ToWireValue(this GtsRefValidationMode mode) => mode switch + { + GtsRefValidationMode.None => "none", + GtsRefValidationMode.AnyPresent => "any-present", + _ => "any-valid", + }; +} diff --git a/Gts.Store/GtsRefValidator.cs b/Gts.Store/GtsRefValidator.cs new file mode 100644 index 0000000..de2f820 --- /dev/null +++ b/Gts.Store/GtsRefValidator.cs @@ -0,0 +1,310 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store.Validation; + +namespace Gts.Store; + +public static class GtsRefValidator +{ + public static IReadOnlyList ValidatePatterns(JsonObject schema, string selectedTypeId) => + ValidateSchema(schema, selectedTypeId, Array.Empty(), GtsRefValidationMode.None); + + public static IReadOnlyList ValidateConstraints(JsonObject schema, string selectedTypeId, IEnumerable entities, GtsRefValidationMode mode) => + ValidateSchema(schema, selectedTypeId, entities, mode); + + internal static IReadOnlyList ValidateSchema( + JsonObject schema, + string selectedTypeId, + IEnumerable entities, + GtsRefValidationMode mode) + { + var errors = new List(); + WalkSchema(schema, schema, selectedTypeId, RefContext.Create(entities), mode, errors, 0); + return errors; + } + + internal static IReadOnlyList ValidateInstance( + JsonObject instance, + JsonObject schema, + string selectedTypeId, + IEnumerable entities, + GtsRefValidationMode mode) + { + var errors = new List(); + WalkInstance(instance, schema, schema, selectedTypeId, + RefContext.Create(entities), mode, "", errors, new HashSet(), 0); + return errors; + } + + private static void WalkSchema( + JsonNode? node, + JsonObject root, + string selectedTypeId, + RefContext context, + GtsRefValidationMode mode, + List errors, + int depth) + { + if (depth >= GtsConstants.MaxNestingDepth) + return; + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue(GtsSchemaKeywords.Ref, out var refNode)) + { + if (refNode is not JsonValue value || !value.TryGetValue(out var pattern)) + errors.Add("x-gts-ref value must be a string"); + else if (!TryResolvePattern(pattern, selectedTypeId, out var resolved)) + errors.Add(pattern.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) && !pattern.Contains('*') + ? $"Invalid GTS identifier: {pattern}" + : $"Invalid x-gts-ref value: '{pattern}'"); + else if (mode != GtsRefValidationMode.None && resolved != GtsConstants.IdPrefix + "*") + { + var matches = context.Entities.Where(entity => Matches(entity.GtsId!.Id, resolved)).ToList(); + if (matches.Count == 0) + errors.Add(resolved.Contains('*') + ? $"x-gts-ref wildcard constraint '{resolved}' has no registered match" + : $"x-gts-ref constraint type '{resolved}' is not registered"); + else if (mode == GtsRefValidationMode.AnyValid && matches.All(entity => !context.ReferencedEntityValid(entity))) + errors.Add($"x-gts-ref constraint '{resolved}' has no valid registered match"); + } + } + foreach (var keyword in new[] { "properties", "patternProperties", "definitions", "$defs", "dependentSchemas" }) + { + if (obj[keyword] is JsonObject map) + { + foreach (var child in map.Select(property => property.Value)) + WalkSchema(child, root, selectedTypeId, context, mode, errors, depth + 1); + } + } + foreach (var keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (obj[keyword] is JsonArray branches) + { + foreach (var child in branches) + WalkSchema(child, root, selectedTypeId, context, mode, errors, depth + 1); + } + } + foreach (var keyword in new[] { "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else", GtsSchemaKeywords.TraitsSchema }) + WalkSchema(obj[keyword], root, selectedTypeId, context, mode, errors, depth + 1); + } + } + + private static void WalkInstance( + JsonNode? instance, + JsonNode? schema, + JsonObject root, + string selectedTypeId, + RefContext context, + GtsRefValidationMode mode, + string path, + List errors, + HashSet localRefs, + int depth) + { + if (depth >= GtsConstants.MaxNestingDepth) + return; + if (schema is not JsonObject obj) + return; + + if (obj["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && reference.StartsWith('#') && localRefs.Add(reference)) + { + if (!GtsJsonPointer.TryEvaluate(root, reference, out var target)) + errors.Add($"Local reference '{reference}' not found"); + else + WalkInstance(instance, target, root, selectedTypeId, context, mode, path, errors, localRefs, depth + 1); + } + + if (obj.TryGetPropertyValue(GtsSchemaKeywords.Ref, out var refNode)) + { + if (refNode is not JsonValue patternValue || !patternValue.TryGetValue(out var pattern) || + !TryResolvePattern(pattern, selectedTypeId, out var resolved)) + errors.Add($"{path}: invalid x-gts-ref constraint"); + else if (instance is not JsonValue instanceValue || !instanceValue.TryGetValue(out var referencedId)) + errors.Add($"{path}: x-gts-ref value must be a string"); + else if (!GtsId.TryParse(referencedId, out var parsed) || parsed is null || parsed.IsInstance && parsed.Segments.Count == 1) + errors.Add($"{path}: value is not a valid GTS identifier"); + else if (!Matches(referencedId, resolved)) + errors.Add($"{path}: value '{referencedId}' does not match pattern '{resolved}'"); + else if (mode != GtsRefValidationMode.None) + { + var referenced = context.FindById(referencedId); + if (referenced is null) + errors.Add($"{path}: referenced entity '{referencedId}' not found in registry"); + else if (mode == GtsRefValidationMode.AnyValid && !context.ReferencedEntityValid(referenced)) + errors.Add($"{path}: referenced entity '{referencedId}' is not valid against its GTS Type Schema"); + } + } + + foreach (var keyword in new[] { "oneOf", "anyOf" }) + { + if (obj[keyword] is not JsonArray branches) + continue; + // Only branches that actually declare an x-gts-ref (directly or nested) participate + // in the reference-match count. A branch without one - e.g. the {"type":"null"} arm of a + // nullable reference - is neutral: this pass never inspects its structural keywords, so it + // would otherwise always look like a match and make a valid value fail oneOf's exactly-one + // rule. Structural oneOf/anyOf selection is left to the JSON Schema engine. + var refBranchResults = new List>(); + var hasNeutralBranch = false; + foreach (var branch in branches) + { + if (!ContainsXGtsRef(branch)) + { + hasNeutralBranch = true; + continue; + } + var branchErrors = new List(); + WalkInstance(instance, branch, root, selectedTypeId, context, mode, path, branchErrors, new HashSet(localRefs), depth + 1); + refBranchResults.Add(branchErrors); + } + var matching = refBranchResults.Count(result => result.Count == 0); + if (refBranchResults.Count > 0 && (matching == 0 && !hasNeutralBranch || keyword == "oneOf" && matching > 1)) + errors.Add($"{path}: {keyword}: expected {(keyword == "oneOf" ? "exactly one" : "at least one")} matching x-gts-ref branch"); + } + if (obj["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf) + WalkInstance(instance, branch, root, selectedTypeId, context, mode, path, errors, new HashSet(localRefs), depth + 1); + } + + if (instance is JsonObject instanceObject && obj["properties"] is JsonObject properties) + { + foreach (var (name, propertySchema) in properties) + { + if (instanceObject.TryGetPropertyValue(name, out var propertyValue)) + WalkInstance(propertyValue, propertySchema, root, selectedTypeId, context, mode, + string.IsNullOrEmpty(path) ? name : path + "." + name, errors, new HashSet(localRefs), depth + 1); + } + } + if (instance is JsonArray instanceArray) + { + if (obj["prefixItems"] is JsonArray prefixItems) + { + for (var index = 0; index < Math.Min(instanceArray.Count, prefixItems.Count); index++) + WalkInstance(instanceArray[index], prefixItems[index], root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + if (obj["items"] is JsonObject overflowItems) + { + for (var index = prefixItems.Count; index < instanceArray.Count; index++) + WalkInstance(instanceArray[index], overflowItems, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + } + } + else if (obj["items"] is JsonArray tupleItems) + { + for (var index = 0; index < Math.Min(instanceArray.Count, tupleItems.Count); index++) + WalkInstance(instanceArray[index], tupleItems[index], root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + if (obj["additionalItems"] is JsonObject additional) + { + for (var index = tupleItems.Count; index < instanceArray.Count; index++) + WalkInstance(instanceArray[index], additional, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + } + } + else if (obj["items"] is JsonObject items) + { + for (var index = 0; index < instanceArray.Count; index++) + WalkInstance(instanceArray[index], items, root, selectedTypeId, context, mode, $"{path}[{index}]", errors, new HashSet(localRefs), depth + 1); + } + } + } + + private static bool TryResolvePattern(string pattern, string selectedTypeId, out string resolved) + { + var valid = GtsRefConstraint.TryCreate(pattern, selectedTypeId, out var constraint); + resolved = valid ? constraint.Pattern : pattern; + return valid; + } + + private static bool Matches(string value, string pattern) => new GtsRefConstraint(pattern).Matches(value); + + /// + /// Whether a schema node carries an x-gts-ref declaration anywhere within it, so that + /// combinator branches without one can be treated as neutral rather than always-matching. + /// + private static bool ContainsXGtsRef(JsonNode? node) => node switch + { + JsonObject obj => obj.ContainsKey(GtsSchemaKeywords.Ref) || obj.Any(property => ContainsXGtsRef(property.Value)), + JsonArray array => array.Any(ContainsXGtsRef), + _ => false, + }; + + /// + /// Per-validation lookup context: an id -> entity index (built once), a lazily-built map of + /// evaluation-normalized schemas, and a memo of referenced-entity validity. This replaces the previous + /// per-reference linear scans and per-reference schema-map rebuilds (which were O(N) each, O(N^2) overall). + /// + private sealed class RefContext + { + private readonly Dictionary _byId; + private Dictionary? _normalizedSchemas; + private readonly Dictionary _validity = new(StringComparer.Ordinal); + + private RefContext(List entities, Dictionary byId) + { + Entities = entities; + _byId = byId; + } + + /// Entities that carry a GTS id (the only ones referenceable by x-gts-ref). + public List Entities { get; } + + public static RefContext Create(IEnumerable entities) + { + var list = entities.Where(entity => entity.GtsId is not null).ToList(); + var byId = new Dictionary(list.Count, StringComparer.Ordinal); + foreach (var entity in list) + byId[entity.GtsId!.Id] = entity; + return new RefContext(list, byId); + } + + public GtsJsonEntity? FindById(string id) => _byId.GetValueOrDefault(id); + + private JsonObject? LoadSchema(GtsId id) => + _byId.TryGetValue(id.Id, out var entity) && entity.IsSchema ? entity.Content : null; + + private Dictionary NormalizedSchemas() + { + if (_normalizedSchemas is not null) + return _normalizedSchemas; + + _normalizedSchemas = new Dictionary(); + foreach (var entity in Entities) + { + if (entity.IsSchema && entity.GtsId is not null) + _normalizedSchemas[entity.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(entity.Content); + } + return _normalizedSchemas; + } + + public bool ReferencedEntityValid(GtsJsonEntity entity) + { + var key = entity.GtsId?.Id; + if (key is not null && _validity.TryGetValue(key, out var cached)) + return cached; + + var result = ComputeValidity(entity); + if (key is not null) + _validity[key] = result; + return result; + } + + private bool ComputeValidity(GtsJsonEntity entity) + { + if (entity.IsSchema && entity.GtsId is not null) + return GtsSchemaTraitsValidator.Validate(entity.GtsId, entity.Content, LoadSchema, Entities, GtsRefValidationMode.None).Count == 0; + + if (string.IsNullOrEmpty(entity.SchemaId) || !GtsId.TryParse(entity.SchemaId, out var schemaId) || schemaId is null) + return false; + + var schemas = NormalizedSchemas(); + if (!schemas.ContainsKey(schemaId)) + return false; + try + { + return GtsJsonSchemaEvaluator.Evaluate(entity.Content, schemaId, schemas).IsValid; + } + catch + { + return false; + } + } + } +} diff --git a/Gts.Store/GtsRegistry.cs b/Gts.Store/GtsRegistry.cs index 168f959..d5e6d49 100644 --- a/Gts.Store/GtsRegistry.cs +++ b/Gts.Store/GtsRegistry.cs @@ -10,7 +10,10 @@ namespace Gts.Store; public abstract class GtsRegistry { private readonly IGtsStore _store; - + private readonly GtsSchemaValidationService _schemaValidation; + private readonly GtsInstanceValidationService _instanceValidation; + private readonly GtsCastService _casting; + /// Registry configuration (e.g. reference validation). public GtsRegistryConfig Config { get; } @@ -19,18 +22,29 @@ protected GtsRegistry(IGtsStore store, GtsRegistryConfig config) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(config); - + _store = store; Config = config; + _schemaValidation = new GtsSchemaValidationService(store); + _instanceValidation = new GtsInstanceValidationService(store, (id, token, mode) => _schemaValidation.ValidateStoredAsync(id.Id, token, mode)); + _casting = new GtsCastService(store); } - + /// Stores or overwrites the entity in the registry. public ValueTask SaveAsync(GtsJsonEntity entity) { - // TODO: validation logic return _store.SaveAsync(entity); } + /// + /// Atomically stores the entity unless one with the same id already exists with different content. + /// Returns in that case without mutating the store. + /// + public ValueTask TrySaveAsync(GtsJsonEntity entity) + { + return _store.TrySaveAsync(entity); + } + /// Retrieves an entity by GTS ID, or null if not found. public ValueTask GetAsync(GtsId id) { @@ -51,6 +65,53 @@ public ValueTask> GetAllAsync() return _store.GetAllAsync(); } + /// + /// Stages an entity WITHOUT publishing it (invisible to public reads until it is committed), returning a + /// unique staging token. Used by validate=true registration so an entity that has not yet passed validation + /// is never observable. Passing a shared lets a batch resolve its own + /// intra-batch references regardless of order while staying isolated from other requests' staged entries. + /// + public ValueTask StageAsync(GtsJsonEntity entity, string? stagingSessionId = null) + { + return _store.StageAsync(entity, stagingSessionId); + } + + /// + /// Publishes a previously staged entity by its staging token, atomically re-checking for a conflicting + /// committed entity and reporting the . + /// + public ValueTask CommitStagedAsync(string token) + { + return _store.CommitStagedAsync(token); + } + + /// + /// Atomically publishes a set of staged tokens all-or-nothing: if any target conflicts, nothing is + /// published. Returns one per token, positionally aligned with + /// . See . + /// + public ValueTask> CommitStagedBatchAsync(IReadOnlyList tokens) + { + return _store.CommitStagedBatchAsync(tokens); + } + + /// Discards a staged entity by its staging token; the committed state is untouched. + public ValueTask DiscardStagedAsync(string token) + { + return _store.DiscardStagedAsync(token); + } + + /// + /// Returns a read-only snapshot of all entities without deep-cloning their content. The returned entities + /// must be treated as read-only; intended for internal read-only consumers such as validation. Committed + /// entities are overlaid only with the staged entries of (null = + /// committed-only), so one request never observes another's unvalidated staged entries. + /// + public ValueTask> SnapshotForReadAsync(string? stagingSessionId = null) + { + return _store.SnapshotForReadAsync(stagingSessionId); + } + /// Returns the number of entities in the registry. public ValueTask CountAsync() { @@ -74,7 +135,10 @@ public async ValueTask QueryAsync( if (!GtsQuery.TryParse(expr, out var parsed, out var parseError) || parsed is null) return GtsQueryExecutionResult.Failed(lim, parseError ?? "Invalid query"); - var all = await _store.GetAllAsync().ConfigureAwait(false); + // Read-only snapshot avoids cloning the whole registry up front; GtsQuery.Execute deep-clones + // only the entities that actually match (single clone per result instead of one per entity). + // Committed-only: a public query must never surface a staged (not-yet-committed) entity. + var all = await _store.SnapshotCommittedForReadAsync().ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); var results = GtsQuery.Execute(all, parsed, lim, cancellationToken); return GtsQueryExecutionResult.Success(lim, results); @@ -147,109 +211,19 @@ public async ValueTask GetAttributeAsync( /// /// GTS instance id or opaque id (e.g. UUID). /// Cancellation token. - public async ValueTask ValidateInstanceAsync( + public ValueTask ValidateInstanceAsync( string instanceId, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(instanceId)) - return new GtsInstanceValidationResult { Ok = false, Id = instanceId }; - - var trimmed = instanceId.Trim(); - var entity = await _store.GetByInstanceIdAsync(trimmed).ConfigureAwait(false); - if (entity is null) - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "InstanceNotFound" }; - - if (entity.IsSchema) - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "NotAnInstance" }; - - var extract = GtsJsonEntity.ExtractId(entity.Content); - var schemaIdStr = extract.SchemaId; - if (string.IsNullOrEmpty(schemaIdStr) || !schemaIdStr.EndsWith('~')) - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "SchemaIdMissing" }; - - if (!GtsId.TryParse(schemaIdStr, out var schemaGtsId) || schemaGtsId is null || !schemaGtsId.IsType) - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "InvalidSchemaId" }; - - var schemaEntity = await _store.GetAsync(schemaGtsId).ConfigureAwait(false); - if (schemaEntity is null || !schemaEntity.IsSchema) - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "SchemaNotFound" }; - - var all = await _store.GetAllAsync().ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - var normalizedMap = new Dictionary(); - foreach (var e in all) - { - if (!e.IsSchema || e.GtsId is null) - continue; - normalizedMap[e.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(e.Content); - } - - if (!normalizedMap.ContainsKey(schemaGtsId)) - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "SchemaNotFound" }; - - JsonDocument instDoc; - try - { - instDoc = JsonDocument.Parse(entity.Content.ToJsonString()); - } - catch (JsonException) - { - return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "InvalidInstanceJson" }; - } - - using (instDoc) - { - var results = GtsJsonSchemaEvaluator.Evaluate(instDoc.RootElement, schemaGtsId, normalizedMap); - - if (results.IsValid) - return new GtsInstanceValidationResult { Ok = true, Id = trimmed }; - - return new GtsInstanceValidationResult - { - Ok = false, - Id = trimmed, - FailureReason = "SchemaValidationFailed", - SchemaErrors = GtsJsonSchemaEvaluator.FlattenErrors(results) - }; - } - } + CancellationToken cancellationToken = default, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default) => + _instanceValidation.ValidateStoredAsync(instanceId, cancellationToken, refValidationMode); - public async ValueTask ValidateJsonAsync( + public ValueTask ValidateJsonAsync( JsonObject content, GtsId schemaGtsId, string? instanceId = null, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var schemaEntity = await _store.GetAsync(schemaGtsId).ConfigureAwait(false); - if (schemaEntity is null) - return new GtsInstanceValidationResult { Ok = false, Id = instanceId, FailureReason = "SchemaNotFound" }; - if (!schemaEntity.IsSchema) - return new GtsInstanceValidationResult { Ok = false, Id = instanceId, FailureReason = "NotASchema" }; - - var all = await _store.GetAllAsync().ConfigureAwait(false); - var normalizedMap = new Dictionary(); - foreach (var entity in all) - { - if (entity.IsSchema && entity.GtsId is not null) - normalizedMap[entity.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(entity.Content); - } - - using var document = JsonDocument.Parse(content.ToJsonString()); - var results = GtsJsonSchemaEvaluator.Evaluate(document.RootElement, schemaGtsId, normalizedMap); - return results.IsValid - ? new GtsInstanceValidationResult { Ok = true, Id = instanceId } - : new GtsInstanceValidationResult - { - Ok = false, - Id = instanceId, - FailureReason = "SchemaValidationFailed", - SchemaErrors = GtsJsonSchemaEvaluator.FlattenErrors(results) - }; - } + CancellationToken cancellationToken = default, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default) => + _instanceValidation.ValidateAsync(content, schemaGtsId, instanceId, cancellationToken, refValidationMode); /// /// Validates a stored schema: $ref must be local (#) or gts://, and the schema must be @@ -257,58 +231,11 @@ public async ValueTask ValidateJsonAsync( /// /// GTS type id of the schema (trailing ~). /// Cancellation token. - public async ValueTask ValidateSchemaAsync( + public ValueTask ValidateSchemaAsync( string schemaTypeId, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(schemaTypeId)) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = schemaTypeId, - FailureReason = "InvalidSchemaId" - }; - } - - var trimmed = schemaTypeId.Trim(); - if (!GtsId.TryParse(trimmed, out var gid) || gid is null || !gid.IsType) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = trimmed, - FailureReason = "InvalidSchemaId" - }; - } - - var entity = await _store.GetAsync(gid).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - if (entity is null) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = trimmed, - FailureReason = "SchemaNotFound" - }; - } - - if (!entity.IsSchema) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = trimmed, - FailureReason = "NotASchema" - }; - } - - return await ValidateSchemaAsync(gid, entity.Content, cancellationToken).ConfigureAwait(false); - } + CancellationToken cancellationToken = default, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default) => + _schemaValidation.ValidateStoredAsync(schemaTypeId, cancellationToken, refValidationMode); /// /// Validates a schema document for a GTS type id using stored precedent schemas only (the document itself need not be in the registry). @@ -316,76 +243,24 @@ public async ValueTask ValidateSchemaAsync( /// GTS type id this document defines (trailing ~). /// JSON Schema body (e.g. from extraction). /// Cancellation token. - public async ValueTask ValidateSchemaAsync( + public ValueTask ValidateSchemaAsync( GtsId schemaTypeId, JsonObject schemaDocument, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(schemaTypeId); - ArgumentNullException.ThrowIfNull(schemaDocument); - - cancellationToken.ThrowIfCancellationRequested(); - - if (!schemaTypeId.IsType) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = schemaTypeId.Id, - FailureReason = "InvalidSchemaId" - }; - } - - var idStr = schemaTypeId.Id; - - try - { - GtsSchemaRefFormatValidator.ValidateRefs(schemaDocument); - } - catch (Exception ex) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = idStr, - FailureReason = "InvalidRefFormat", - Errors = new[] { ex.Message } - }; - } - - JsonObject? LoadSchema(GtsId id) - { - var t = _store.GetAsync(id).AsTask().GetAwaiter().GetResult(); - return t?.IsSchema == true ? t.Content : null; - } - - var (derivOk, derivErrors) = GtsSchemaDerivationValidator.ValidateAgainstRegistry( - schemaTypeId, - schemaDocument, - LoadSchema); - if (!derivOk) - { - return new GtsSchemaValidationResult - { - Ok = false, - SchemaId = idStr, - FailureReason = "PrecedentIncompatible", - Errors = derivErrors - }; - } - - return new GtsSchemaValidationResult { Ok = true, SchemaId = idStr }; - } + CancellationToken cancellationToken = default, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default, + string? stagingSessionId = null) => + _schemaValidation.ValidateAsync(schemaTypeId, schemaDocument, cancellationToken, refValidationMode, stagingSessionId); /// /// Validates a stored schema by type id (see ). /// public ValueTask ValidateSchemaAsync( GtsId schemaTypeId, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default) { ArgumentNullException.ThrowIfNull(schemaTypeId); - return ValidateSchemaAsync(schemaTypeId.Id, cancellationToken); + return ValidateSchemaAsync(schemaTypeId.Id, cancellationToken, refValidationMode); } /// @@ -461,233 +336,24 @@ public async ValueTask CompareMinorVersionSchemas /// GTS instance id or opaque id (e.g. UUID). /// Target schema type id (trailing ~). /// Cancellation token. - public async ValueTask CastInstanceAsync( + public ValueTask CastInstanceAsync( string instanceId, GtsId toSchemaId, - CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(instanceId)) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = instanceId, - FailureReason = "InvalidInstanceId" - }; - } - - ArgumentNullException.ThrowIfNull(toSchemaId); - - cancellationToken.ThrowIfCancellationRequested(); - var trimmed = instanceId.Trim(); - var entity = await _store.GetByInstanceIdAsync(trimmed).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - if (entity is null) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - ToSchemaId = toSchemaId, - FailureReason = "InstanceNotFound" - }; - } - - if (entity.IsSchema) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - ToSchemaId = toSchemaId, - FailureReason = "NotAnInstance" - }; - } - - if (!toSchemaId.IsType) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - ToSchemaId = toSchemaId, - FailureReason = "InvalidTargetSchemaId" - }; - } - - var toSchemaEntity = await _store.GetAsync(toSchemaId).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - if (toSchemaEntity is null || !toSchemaEntity.IsSchema) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - ToSchemaId = toSchemaId, - FailureReason = "TargetSchemaNotFound" - }; - } + CancellationToken cancellationToken = default) => + _casting.CastAsync(instanceId, toSchemaId, cancellationToken); - var extract = GtsJsonEntity.ExtractId(entity.Content); - var fromSchemaIdStr = extract.SchemaId; - if (string.IsNullOrEmpty(fromSchemaIdStr) || !GtsId.TryParse(fromSchemaIdStr, out var fromGid) || fromGid is null || !fromGid.IsType) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - ToSchemaId = toSchemaId, - FailureReason = "SchemaIdMissing" - }; - } - - var fromSchemaEntity = await _store.GetAsync(fromGid).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - if (fromSchemaEntity is null || !fromSchemaEntity.IsSchema) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - FromSchemaId = fromGid, - ToSchemaId = toSchemaId, - FailureReason = "SourceSchemaNotFound" - }; - } - - var comparison = GtsSchemaMinorVersionCompatibility.ComparePair( - fromGid, - fromSchemaEntity.Content, - toSchemaId, - toSchemaEntity.Content); - - if (!comparison.AreMinorVariantPair || !EvolutionAllowsCast(fromGid, toSchemaId, comparison)) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - FromSchemaId = fromGid, - ToSchemaId = toSchemaId, - FailureReason = !comparison.AreMinorVariantPair ? "NotMinorVariantPair" : "IncompatibleMinorEvolution", - Comparison = comparison - }; - } - - var targetFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(toSchemaEntity.Content); - var casted = GtsInstanceCast.CastToEffectiveSchema(entity.Content, targetFlat); - - var all = await _store.GetAllAsync().ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - - var normalizedMap = new Dictionary(); - foreach (var e in all) - { - if (!e.IsSchema || e.GtsId is null) - continue; - normalizedMap[e.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(e.Content); - } - - if (!normalizedMap.ContainsKey(toSchemaId)) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - FromSchemaId = fromGid, - ToSchemaId = toSchemaId, - FailureReason = "SchemaNormalizationFailed", - Comparison = comparison, - CastedContent = casted - }; - } - - var tolerant = (JsonObject)GtsInstanceCast.RemoveGtsConstConstraints( - JsonNode.Parse(toSchemaEntity.Content.ToJsonString())!)!.AsObject(); - normalizedMap[toSchemaId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(tolerant); - - JsonDocument instDoc; - try - { - instDoc = JsonDocument.Parse(casted.ToJsonString()); - } - catch (JsonException) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - FromSchemaId = fromGid, - ToSchemaId = toSchemaId, - FailureReason = "InvalidCastedJson", - Comparison = comparison, - CastedContent = casted - }; - } - - using (instDoc) - { - var eval = GtsJsonSchemaEvaluator.Evaluate(instDoc.RootElement, toSchemaId, normalizedMap); - if (!eval.IsValid) - { - return new GtsInstanceCastResult - { - Ok = false, - InstanceId = trimmed, - FromSchemaId = fromGid, - ToSchemaId = toSchemaId, - FailureReason = "CastValidationFailed", - Comparison = comparison, - CastedContent = casted, - SchemaValidationErrors = GtsJsonSchemaEvaluator.FlattenErrors(eval) - }; - } - } - - return new GtsInstanceCastResult - { - Ok = true, - InstanceId = trimmed, - FromSchemaId = fromGid, - ToSchemaId = toSchemaId, - CastedContent = casted, - Comparison = comparison - }; - } - - private static bool EvolutionAllowsCast(GtsId fromSchemaId, GtsId toSchemaId, GtsMinorVersionPairComparison cmp) - { - if (!cmp.AreMinorVariantPair || cmp.OlderSchemaId is null || cmp.NewerSchemaId is null) - return false; - - if (string.Equals(fromSchemaId.Id, toSchemaId.Id, StringComparison.Ordinal)) - return true; - - var fromIsOlder = string.Equals(fromSchemaId.Id, cmp.OlderSchemaId.Id, StringComparison.Ordinal); - var toIsNewer = string.Equals(toSchemaId.Id, cmp.NewerSchemaId.Id, StringComparison.Ordinal); - if (fromIsOlder && toIsNewer) - return cmp.IsBackwardEvolutionCompatible; - - var fromIsNewer = string.Equals(fromSchemaId.Id, cmp.NewerSchemaId.Id, StringComparison.Ordinal); - var toIsOlder = string.Equals(toSchemaId.Id, cmp.OlderSchemaId.Id, StringComparison.Ordinal); - if (fromIsNewer && toIsOlder) - return cmp.IsForwardEvolutionCompatible; - - return false; - } - - /// Creates an in-memory registry (single-threaded). + /// Creates an in-memory registry. The in-memory store is thread-safe. public static GtsRegistry InMemory(GtsRegistryConfig config) { - return InMemoryGtsRegistry.Simple(config); + return InMemoryGtsRegistry.Create(config); } - /// Creates an in-memory registry with thread-safe storage. + /// + /// Creates an in-memory registry. Retained for API compatibility; the in-memory store is always + /// thread-safe, so this is equivalent to . + /// public static GtsRegistry InMemoryThreadSafe(GtsRegistryConfig config) { - return InMemoryGtsRegistry.Concurrent(config); + return InMemoryGtsRegistry.Create(config); } } diff --git a/Gts.Store/GtsSchemaCompatibilityService.cs b/Gts.Store/GtsSchemaCompatibilityService.cs new file mode 100644 index 0000000..c693319 --- /dev/null +++ b/Gts.Store/GtsSchemaCompatibilityService.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +public static class GtsSchemaCompatibilityService +{ + internal static IReadOnlyList ValidateDerivation(JsonObject parent, JsonObject child) => + GtsSchemaDerivationValidator.ValidateCompatibilityCore(parent, child); + + internal static IReadOnlyList ValidateTraitOverlay(JsonObject parent, JsonObject overlay) => + GtsSchemaDerivationValidator.ValidateOverlayCore(parent, overlay); + + public static (bool Backward, IReadOnlyList BackwardErrors, bool Forward, IReadOnlyList ForwardErrors) + CompareEvolution(JsonObject oldSchema, JsonObject newSchema) + { + var (backward, backwardErrors) = GtsJsonSchemaEvolutionCompatibility.CheckBackward(oldSchema, newSchema); + var (forward, forwardErrors) = GtsJsonSchemaEvolutionCompatibility.CheckForward(oldSchema, newSchema); + return (backward, backwardErrors, forward, forwardErrors); + } +} \ No newline at end of file diff --git a/Gts.Store/GtsSchemaDependencyGraph.cs b/Gts.Store/GtsSchemaDependencyGraph.cs new file mode 100644 index 0000000..b5df793 --- /dev/null +++ b/Gts.Store/GtsSchemaDependencyGraph.cs @@ -0,0 +1,131 @@ +using System.Text.Json.Nodes; +using Gts.Store.Validation; + +namespace Gts.Store; + +internal static class GtsSchemaDependencyGraph +{ + internal static string Dialect(JsonObject schema) + { + var value = schema["$schema"]?.GetValue() ?? "http://json-schema.org/draft-07/schema#"; + if (value.Contains("draft-07", StringComparison.Ordinal)) return "draft-07"; + if (value.Contains("2019-09", StringComparison.Ordinal)) return "2019-09"; + if (value.Contains("2020-12", StringComparison.Ordinal)) return "2020-12"; + return value.TrimEnd('#'); + } + + internal static JsonObject Resolve(JsonObject schema, Func load) => Resolve(schema, load, new HashSet(), 0); + + internal static IEnumerable References(JsonNode? node) + { + if (node is JsonObject obj) + { + if (obj["$ref"] is JsonValue value && value.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + yield return GtsConstants.StripUriPrefix(reference).Split('#')[0]; + foreach (var child in obj.Select(property => property.Value)) + { + foreach (var nested in References(child)) + yield return nested; + } + } + else if (node is JsonArray array) + { + foreach (var child in array) + { + foreach (var nested in References(child)) + yield return nested; + } + } + } + + internal static bool HasCycle(string id, JsonObject schema, Func load) => + HasCycle(id, schema, load, new HashSet(), new HashSet()); + + internal static void ValidateDialects(JsonNode? node, string rootDialect, Func load, List errors) + { + if (node is JsonObject obj) + { + if (obj["$schema"] is JsonValue && Dialect(obj) != rootDialect) + errors.Add("Embedded JSON Schema resource uses a different dialect from the root schema"); + if (obj["$ref"] is JsonValue value && value.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + { + var id = GtsConstants.StripUriPrefix(reference).Split('#')[0]; + if (!GtsId.TryParse(id, out var parsed) || parsed is null || load(parsed) is not JsonObject target) + errors.Add($"Referenced GTS Type Schema '{id}' not found"); + else if (rootDialect != Dialect(target)) + errors.Add($"Referenced GTS Type Schema '{id}' uses a different JSON Schema dialect"); + } + foreach (var child in obj.Select(property => property.Value)) + ValidateDialects(child, rootDialect, load, errors); + } + else if (node is JsonArray array) + { + foreach (var child in array) + ValidateDialects(child, rootDialect, load, errors); + } + } + + private static bool HasCycle(string id, JsonObject schema, Func load, HashSet visiting, HashSet visited) + { + if (!visiting.Add(id)) return true; + if (visited.Contains(id)) + { + visiting.Remove(id); + return false; + } + foreach (var reference in References(schema)) + { + if (GtsId.TryParse(reference, out var parsed) && parsed is not null && load(parsed) is JsonObject target && HasCycle(reference, target, load, visiting, visited)) + return true; + } + visiting.Remove(id); + visited.Add(id); + return false; + } + + private static JsonObject Resolve(JsonObject schema, Func load, HashSet stack, int depth) + { + // Inlining $ref targets can build a tree deeper than any single input document (a chain of N + // distinct type schemas resolves to depth ~N), so cap it independently of the JSON parser's + // own depth limit. Beyond the cap we stop inlining and return the node as-is. + if (depth >= GtsConstants.MaxNestingDepth) + return (JsonObject)schema.DeepClone(); + + if (schema["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + { + // A $ref may carry a JSON Pointer fragment (e.g. "gts://…~#/definitions/address") that + // selects a subschema of the target, and — under 2019-09/2020-12 — may sit next to sibling + // keywords that also apply. Evaluate the fragment against the loaded document and preserve + // any siblings by composing them with the resolved target via allOf. + var parts = GtsConstants.StripUriPrefix(reference).Split('#', 2); + var id = parts[0]; + if (GtsId.TryParse(id, out var parsed) && parsed is not null && stack.Add(reference) && load(parsed) is JsonObject document) + { + JsonObject? target = parts.Length == 2 && parts[1].Length > 0 + ? (GtsJsonPointer.TryEvaluate(document, "#" + parts[1], out var fragment) ? fragment as JsonObject : null) + : document; + if (target is not null) + { + var resolved = Resolve(target, load, stack, depth + 1); + stack.Remove(reference); + var siblings = new JsonObject(); + foreach (var (key, value) in schema) + if (key != "$ref") siblings[key] = value?.DeepClone(); + if (siblings.Count == 0) + return resolved; + return new JsonObject { ["allOf"] = new JsonArray(resolved, Resolve(siblings, load, stack, depth + 1)) }; + } + stack.Remove(reference); + } + } + var clone = new JsonObject(); + foreach (var (key, value) in schema) + clone[key] = value switch + { + JsonObject obj => Resolve(obj, load, stack, depth + 1), + JsonArray array => new JsonArray(array.Select(item => item is JsonObject child ? Resolve(child, load, stack, depth + 1) : item?.DeepClone()).ToArray()), + _ => value?.DeepClone() + }; + return clone; + } +} \ No newline at end of file diff --git a/Gts.Store/GtsSchemaDerivationValidator.cs b/Gts.Store/GtsSchemaDerivationValidator.cs index dfa79d7..0e2f01c 100644 --- a/Gts.Store/GtsSchemaDerivationValidator.cs +++ b/Gts.Store/GtsSchemaDerivationValidator.cs @@ -17,6 +17,11 @@ public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( while (true) { + var dialect = GtsSchemaDependencyGraph.Dialect(currentSchema); + GtsSchemaDependencyGraph.ValidateDialects(currentSchema, dialect, tryLoadTypeSchema, errors); + ValidateReferenceTargets(currentSchema, dialect, tryLoadTypeSchema, errors, new HashSet()); + if (GetParentTypeId(currentId) is not null && GtsSchemaDependencyGraph.HasCycle(currentId, currentSchema, tryLoadTypeSchema)) + errors.Add($"Schema '{currentId}' contains a cyclic GTS $ref dependency"); var parentIdStr = GetParentTypeId(currentId); if (parentIdStr is null) break; @@ -30,13 +35,20 @@ public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( var parentSchema = tryLoadTypeSchema(parentId); if (parentSchema is null) { - errors.Add($"Precedent schema '{parentIdStr}' not found."); + errors.Add($"Parent GTS Type Schema not found: Precedent schema '{parentIdStr}'."); break; } - var resolvedParent = ResolveRefs(parentSchema, tryLoadTypeSchema, new HashSet()); - var resolvedChild = ResolveRefs(currentSchema, tryLoadTypeSchema, new HashSet()); - ValidateSubset(Flatten(resolvedParent), Flatten(resolvedChild), "", errors); + if (parentSchema[GtsSchemaKeywords.Final] is JsonValue finalValue && finalValue.TryGetValue(out var isFinal) && isFinal) + errors.Add($"Precedent schema '{parentIdStr}' is final and cannot be derived"); + if (GtsSchemaDependencyGraph.Dialect(parentSchema) != GtsSchemaDependencyGraph.Dialect(currentSchema)) + errors.Add($"Schema '{currentId}' uses a different JSON Schema dialect from precedent '{parentIdStr}'"); + var resolvedParent = GtsSchemaDependencyGraph.Resolve(parentSchema, tryLoadTypeSchema); + var resolvedChild = GtsSchemaDependencyGraph.Resolve(currentSchema, tryLoadTypeSchema); + var parentFlat = Flatten(resolvedParent); + ValidateSubset(parentFlat, Flatten(resolvedChild), "", errors); + ValidateRedeclarations(parentFlat, Flatten(currentSchema), "", errors); + ValidateClosedBranches(parentFlat, currentSchema, "", errors); currentId = parentIdStr; currentSchema = parentSchema; @@ -45,6 +57,43 @@ public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( return (errors.Count == 0, errors); } + internal static JsonObject ResolveForEvaluation(JsonObject schema, Func loadSchema) => + GtsSchemaDependencyGraph.Resolve(schema, loadSchema); + + internal static IReadOnlyList ValidateCompatibilityCore(JsonObject parent, JsonObject child) + { + var errors = new List(); + var parentFlat = Flatten(parent); + ValidateSubset(parentFlat, Flatten(child), "", errors); + ValidateRedeclarations(parentFlat, Flatten(child), "", errors); + ValidateClosedBranches(parentFlat, child, "", errors); + return errors; + } + + internal static IReadOnlyList ValidateOverlayCore(JsonObject parent, JsonObject overlay) + { + var errors = new List(); + var parentFlat = Flatten(parent); + var overlayFlat = Flatten(overlay); + if (overlayFlat["properties"] is JsonObject overlayPropertyMap) + { + foreach (var name in overlayPropertyMap.Select(property => property.Key).ToList()) + { + if (overlayPropertyMap[name] is JsonObject property && property.All(entry => entry.Key is "default" or "title" or "description" or "examples")) + overlayPropertyMap.Remove(name); + } + } + ValidateRedeclarations(parentFlat, overlayFlat, "", errors); + ValidateClosedBranches(parentFlat, overlay, "", errors); + if (IsFalse(parentFlat["additionalProperties"]) && overlay["properties"] is JsonObject overlayProperties && + parentFlat["properties"] is JsonObject parentProperties) + { + foreach (var added in overlayProperties.Select(property => property.Key).Except(parentProperties.Select(property => property.Key))) + errors.Add($"{added}: descendant trait schema adds a field to a closed ancestor"); + } + return errors; + } + /// Strips the last ~segment from a chained type id, or returns null for a single-segment base. public static string? GetParentTypeId(string schemaTypeId) { @@ -59,37 +108,50 @@ public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( return withoutTrailing[..(last + 1)].ToString(); } - private static JsonObject ResolveRefs(JsonObject schema, Func load, HashSet stack) + private static void ValidateReferenceTargets( + JsonObject schema, + string rootDialect, + Func load, + List errors, + HashSet visited) { - if (schema["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference) && - reference.StartsWith("gts://", StringComparison.Ordinal)) + foreach (var reference in GtsSchemaDependencyGraph.References(schema)) { - var id = reference[6..]; - if (GtsId.TryParse(id, out var parsed) && parsed is not null && stack.Add(id)) + if (!visited.Add(reference)) + continue; + if (!GtsId.TryParse(reference, out var parsed) || parsed is null || load(parsed) is not JsonObject target) { - var target = load(parsed); - if (target is not null) + errors.Add($"Referenced GTS Type Schema '{reference}' not found"); + continue; + } + if (GtsSchemaDependencyGraph.Dialect(target) != rootDialect) + errors.Add($"Referenced GTS Type Schema '{reference}' uses a different JSON Schema dialect"); + var keywordErrors = GtsSchemaKeywordValidator.Validate(target); + errors.AddRange(keywordErrors.Select(error => $"Referenced GTS Type Schema '{reference}' is invalid: {error}")); + if (parsed is not null) + { + var traitErrors = GtsSchemaTraitsValidator.Validate(parsed, target, load, Array.Empty(), GtsRefValidationMode.None); + errors.AddRange(traitErrors.Select(error => $"Referenced GTS Type Schema '{reference}' is invalid: {error}")); + var ancestorId = GetParentTypeId(reference); + while (ancestorId is not null && GtsId.TryParse(ancestorId, out var ancestorParsed) && ancestorParsed is not null && load(ancestorParsed) is JsonObject ancestor) { - var resolved = ResolveRefs(target, load, stack); - stack.Remove(id); - return resolved; + var ancestorTraitErrors = GtsSchemaTraitsValidator.Validate(ancestorParsed, ancestor, load, Array.Empty(), GtsRefValidationMode.None); + errors.AddRange(ancestorTraitErrors.Select(error => $"Referenced GTS Type Schema ancestor '{ancestorId}' is invalid: {error}")); + ancestorId = GetParentTypeId(ancestorId); } } + if (GetParentTypeId(reference) is { } parentId && GtsId.TryParse(parentId, out var parentParsed) && + parentParsed is not null && load(parentParsed) is JsonObject parentSchema) + { + var compatibilityErrors = GtsSchemaCompatibilityService.ValidateDerivation( + GtsSchemaDependencyGraph.Resolve(parentSchema, load), + GtsSchemaDependencyGraph.Resolve(target, load)); + errors.AddRange(compatibilityErrors.Select(error => $"Referenced GTS Type Schema '{reference}' has an invalid ancestor chain: {error}")); + } + ValidateReferenceTargets(target, rootDialect, load, errors, visited); } - - var clone = new JsonObject(); - foreach (var (key, value) in schema) - clone[key] = ResolveNode(value, load, stack); - return clone; } - private static JsonNode? ResolveNode(JsonNode? node, Func load, HashSet stack) => node switch - { - JsonObject obj => ResolveRefs(obj, load, stack), - JsonArray array => new JsonArray(array.Select(item => ResolveNode(item, load, stack)).ToArray()), - _ => node?.DeepClone() - }; - private static JsonObject Flatten(JsonObject schema) { var result = new JsonObject(); @@ -131,6 +193,15 @@ private static void MergeKeyword(JsonObject target, string key, JsonNode? value) target["properties"] = targetProperties; return; } + if (key == "additionalProperties") + { + if (IsFalse(target[key])) + return; + if (value is JsonValue candidate && candidate.TryGetValue(out var candidateAllowed) && candidateAllowed && target.ContainsKey(key)) + return; + target[key] = value?.DeepClone(); + return; + } if (key == "required" && value is JsonArray required) { var targetRequired = target["required"] as JsonArray ?? new JsonArray(); @@ -156,7 +227,11 @@ private static void ValidateSubset(JsonNode? parent, JsonNode? child, string pat return; } if (child is JsonValue childFalse && childFalse.TryGetValue(out var allowed) && !allowed) + { + if (parent is not JsonValue parentFalse || !parentFalse.TryGetValue(out var parentFalseAllowed) || parentFalseAllowed) + errors.Add($"{path}: derived schema disables a property defined by the base"); return; + } if (parent is not JsonObject parentObject || child is not JsonObject childObject) return; @@ -188,8 +263,17 @@ private static void ValidateSubset(JsonNode? parent, JsonNode? child, string pat } } - if (IsFalse(parentObject["additionalProperties"]) && !IsFalse(childObject["additionalProperties"])) - errors.Add($"{path}: derived schema reopens a closed object"); + if (IsFalse(parentObject["additionalProperties"])) + { + if (!IsFalse(childObject["additionalProperties"])) + errors.Add($"{path}: derived schema reopens a closed object"); + if (childProperties is not null) + { + var parentNames = parentProperties?.Select(property => property.Key).ToHashSet(StringComparer.Ordinal) ?? new HashSet(); + foreach (var added in childProperties.Select(property => property.Key).Where(name => !parentNames.Contains(name))) + errors.Add($"{Join(path, added)}: derived schema adds a property forbidden by a closed base"); + } + } if (parentObject["items"] is JsonNode parentItems) { @@ -200,6 +284,73 @@ private static void ValidateSubset(JsonNode? parent, JsonNode? child, string pat } } + private static void ValidateRedeclarations(JsonObject parent, JsonObject declared, string path, List errors) + { + if (parent["properties"] is not JsonObject parentProperties || declared["properties"] is not JsonObject declaredProperties) + return; + foreach (var (name, childProperty) in declaredProperties) + { + if (parentProperties.TryGetPropertyValue(name, out var parentProperty)) + { + if (parentProperty is JsonObject parentObject && childProperty is JsonObject childObject) + { + var composed = (JsonObject)childObject.DeepClone(); + if (parentObject["properties"] is JsonObject inheritedProperties) + { + var composedProperties = composed["properties"] as JsonObject ?? new JsonObject(); + foreach (var (propertyName, propertySchema) in inheritedProperties) + { + if (!composedProperties.ContainsKey(propertyName)) + composedProperties[propertyName] = propertySchema?.DeepClone(); + } + composed["properties"] = composedProperties; + } + if (parentObject["required"] is JsonArray inheritedRequired) + { + var composedRequired = composed["required"] as JsonArray ?? new JsonArray(); + var names = composedRequired.Select(item => item?.GetValue()).ToHashSet(StringComparer.Ordinal); + foreach (var requiredName in inheritedRequired.OfType().Select(item => item.GetValue())) + { + if (names.Add(requiredName)) + composedRequired.Add(requiredName); + } + composed["required"] = composedRequired; + } + if (!composed.ContainsKey("additionalProperties") && parentObject["additionalProperties"] is JsonNode inheritedAdditional) + composed["additionalProperties"] = inheritedAdditional.DeepClone(); + ValidateSubset(parentProperty, composed, Join(path, name), errors); + } + else + ValidateSubset(parentProperty, childProperty, Join(path, name), errors); + } + } + } + + private static void ValidateClosedBranches(JsonObject ancestor, JsonObject descendant, string path, List errors) + { + if (descendant["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf.OfType()) + ValidateClosedBranches(ancestor, branch, path, errors); + } + if (IsFalse(descendant["additionalProperties"]) && ancestor["properties"] is JsonObject ancestorProperties) + { + var descendantProperties = descendant["properties"] as JsonObject; + foreach (var name in ancestorProperties.Select(property => property.Key)) + { + if (descendantProperties is null || !descendantProperties.ContainsKey(name)) + errors.Add($"{Join(path, name)}: closed descendant branch does not restate an ancestor property"); + } + } + if (ancestor["properties"] is not JsonObject parentProperties || descendant["properties"] is not JsonObject childProperties) + return; + foreach (var (name, childProperty) in childProperties) + { + if (parentProperties[name] is JsonObject parentProperty && childProperty is JsonObject childObject) + ValidateClosedBranches(Flatten(parentProperty), childObject, Join(path, name), errors); + } + } + private static void ValidateTypes(JsonObject parent, JsonObject child, string path, List errors) { var parentTypes = Types(parent["type"]); @@ -208,6 +359,8 @@ private static void ValidateTypes(JsonObject parent, JsonObject child, string pa return; if (childTypes.Count == 0) { + if (FiniteValuesMatchTypes(child, parentTypes)) + return; errors.Add($"{path}: derived schema drops type constraint"); return; } @@ -238,6 +391,15 @@ private static void ValidateEnumAndConst(JsonObject parent, JsonObject child, st errors.Add($"{path}: derived enum adds a value rejected by base"); } } + if (child["const"] is JsonNode childValue) + { + var number = Number(childValue); + if (number is not null && (Number(parent["minimum"]) is decimal minimum && number < minimum || + Number(parent["maximum"]) is decimal maximum && number > maximum)) + errors.Add($"{path}: derived const violates a base numeric bound"); + if (parent["enum"] is JsonArray allowedValues && !allowedValues.Any(value => JsonNode.DeepEquals(value, childValue))) + errors.Add($"{path}: derived const is not allowed by base enum"); + } } private static void ValidateBounds(JsonObject parent, JsonObject child, string path, List errors) @@ -254,10 +416,61 @@ private static void ValidateBound(JsonObject parent, JsonObject child, string pa if (parentValue is null) return; var childValue = Number(child[keyword]); + if (childValue is null && ConstraintProvenByFiniteValues(child, keyword, parentValue.Value, minimum)) + return; if (childValue is null || minimum && childValue < parentValue || !minimum && childValue > parentValue) errors.Add($"{path}: derived schema loosens {keyword}"); } + private static bool FiniteValuesMatchTypes(JsonObject schema, HashSet types) + { + IEnumerable values = schema["enum"] is JsonArray array + ? array + : schema["const"] is JsonNode constant ? new[] { constant } : Array.Empty(); + var found = false; + foreach (var value in values) + { + found = true; + var matches = value switch + { + JsonValue json when json.TryGetValue(out _) => types.Contains("string"), + JsonValue json when json.TryGetValue(out _) => types.Contains("boolean"), + JsonValue json when json.TryGetValue(out _) => types.Contains("integer") || types.Contains("number"), + JsonValue json when json.TryGetValue(out _) => types.Contains("number"), + JsonObject => types.Contains("object"), + JsonArray => types.Contains("array"), + null => types.Contains("null"), + _ => false + }; + if (!matches) + return false; + } + return found; + } + + private static bool ConstraintProvenByFiniteValues(JsonObject child, string keyword, decimal bound, bool minimum) + { + IEnumerable values = child["enum"] is JsonArray array + ? array + : child["const"] is JsonNode constant + ? new[] { constant } + : Array.Empty(); + var found = false; + foreach (var value in values) + { + found = true; + decimal? measured = keyword switch + { + "minLength" or "maxLength" when value is JsonValue stringValue && stringValue.TryGetValue(out var text) => text.Length, + "minItems" or "maxItems" when value is JsonArray itemArray => itemArray.Count, + _ => Number(value) + }; + if (measured is null || minimum && measured < bound || !minimum && measured > bound) + return false; + } + return found; + } + private static HashSet Types(JsonNode? node) { if (node is JsonValue value && value.TryGetValue(out var type)) diff --git a/Gts.Store/GtsSchemaKeywordValidator.cs b/Gts.Store/GtsSchemaKeywordValidator.cs new file mode 100644 index 0000000..410288e --- /dev/null +++ b/Gts.Store/GtsSchemaKeywordValidator.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +public static class GtsSchemaKeywordValidator +{ + private static readonly HashSet TopLevel = + [ + GtsSchemaKeywords.Final, GtsSchemaKeywords.Abstract, GtsSchemaKeywords.Traits, GtsSchemaKeywords.TraitsSchema + ]; + + public static IReadOnlyList Validate(JsonObject schema) + { + var errors = new List(); + ValidateNode(schema, true, errors); + if (schema[GtsSchemaKeywords.Final] is JsonNode final && !TryBoolean(final, out _)) + errors.Add("x-gts-final must be a boolean"); + if (schema[GtsSchemaKeywords.Abstract] is JsonNode abstractNode && !TryBoolean(abstractNode, out _)) + errors.Add("x-gts-abstract must be a boolean"); + if (TryBoolean(schema[GtsSchemaKeywords.Final], out var isFinal) && isFinal && + TryBoolean(schema[GtsSchemaKeywords.Abstract], out var isAbstract) && isAbstract) + errors.Add("schema cannot declare both x-gts-final and x-gts-abstract as true"); + return errors; + } + + private static void ValidateNode(JsonObject schema, bool root, List errors) + { + if (schema.TryGetPropertyValue("type", out var typeNode) && + (typeNode is not JsonValue && typeNode is not JsonArray || + typeNode is JsonValue typeValue && !typeValue.TryGetValue(out _) || + typeNode is JsonArray typeArray && typeArray.Any(item => item is not JsonValue value || !value.TryGetValue(out _)))) + errors.Add("JSON Schema type must be a string or an array of strings"); + + foreach (var key in schema.Select(property => property.Key)) + { + if (key.StartsWith(GtsSchemaKeywords.Prefix, StringComparison.Ordinal) && key != GtsSchemaKeywords.Ref && !TopLevel.Contains(key)) + errors.Add($"Unsupported GTS extension keyword: {key}"); + if (!root && TopLevel.Contains(key)) + errors.Add($"{key} must be at the schema top level"); + } + + RecurseMap(schema["properties"], errors); + RecurseMap(schema["patternProperties"], errors); + RecurseMap(schema["definitions"], errors); + RecurseMap(schema["$defs"], errors); + RecurseMap(schema["dependentSchemas"], errors); + foreach (var key in new[] { "additionalProperties", "additionalItems", "contains", "not", "if", "then", "else", "propertyNames", "unevaluatedProperties", "unevaluatedItems" }) + Recurse(schema[key], errors); + foreach (var key in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + RecurseArray(schema[key], errors); + Recurse(schema["items"], errors); + } + + private static void RecurseMap(JsonNode? node, List errors) + { + if (node is not JsonObject map) + return; + foreach (var child in map.Select(property => property.Value).OfType()) + ValidateNode(child, false, errors); + } + + private static void RecurseArray(JsonNode? node, List errors) + { + if (node is not JsonArray array) + return; + foreach (var child in array.OfType()) + ValidateNode(child, false, errors); + } + + private static void Recurse(JsonNode? node, List errors) + { + if (node is JsonObject child) + ValidateNode(child, false, errors); + else + RecurseArray(node, errors); + } + + private static bool TryBoolean(JsonNode? node, out bool value) + { + value = false; + return node is JsonValue jsonValue && jsonValue.TryGetValue(out value); + } +} diff --git a/Gts.Store/GtsSchemaKeywords.cs b/Gts.Store/GtsSchemaKeywords.cs new file mode 100644 index 0000000..8b96ff8 --- /dev/null +++ b/Gts.Store/GtsSchemaKeywords.cs @@ -0,0 +1,26 @@ +namespace Gts.Store; + +/// +/// Names of the GTS-specific JSON Schema extension keywords (GTS spec §9). Centralized so the keyword +/// spellings live in one place instead of being repeated as string literals across the validators. +/// +public static class GtsSchemaKeywords +{ + /// Common prefix shared by every GTS schema extension keyword. + public const string Prefix = "x-gts-"; + + /// x-gts-ref: constrains a string value to a GTS identifier matching a pattern (§9.6). + public const string Ref = "x-gts-ref"; + + /// x-gts-final: marks a type schema as non-derivable. + public const string Final = "x-gts-final"; + + /// x-gts-abstract: marks a type schema as non-instantiable. + public const string Abstract = "x-gts-abstract"; + + /// x-gts-traits-schema: declares the trait schema for a type and its descendants (§9.7). + public const string TraitsSchema = "x-gts-traits-schema"; + + /// x-gts-traits: supplies concrete values for declared trait properties (§9.7). + public const string Traits = "x-gts-traits"; +} diff --git a/Gts.Store/GtsSchemaMinorVersionCompatibility.cs b/Gts.Store/GtsSchemaMinorVersionCompatibility.cs index a12a72a..4b36088 100644 --- a/Gts.Store/GtsSchemaMinorVersionCompatibility.cs +++ b/Gts.Store/GtsSchemaMinorVersionCompatibility.cs @@ -95,8 +95,7 @@ public static GtsMinorVersionPairComparison ComparePair( var oldFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(olderSchema); var newFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(newerSchema); - var (backOk, backErr) = GtsJsonSchemaEvolutionCompatibility.CheckBackward(oldFlat, newFlat); - var (fwdOk, fwdErr) = GtsJsonSchemaEvolutionCompatibility.CheckForward(oldFlat, newFlat); + var (backOk, backErr, fwdOk, fwdErr) = GtsSchemaCompatibilityService.CompareEvolution(oldFlat, newFlat); return new GtsMinorVersionPairComparison { diff --git a/Gts.Store/GtsSchemaRefFormatValidator.cs b/Gts.Store/GtsSchemaRefFormatValidator.cs index a855a4a..8fcdf8a 100644 --- a/Gts.Store/GtsSchemaRefFormatValidator.cs +++ b/Gts.Store/GtsSchemaRefFormatValidator.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Gts.Extraction; +using Gts.Store.Validation; namespace Gts.Store; @@ -7,7 +8,9 @@ namespace Gts.Store; public static class GtsSchemaRefFormatValidator { /// Throws when an invalid $ref is found. - public static void ValidateRefs(JsonNode? node, string path = "") + public static void ValidateRefs(JsonNode? node, string path = "") => ValidateRefs(node, node, path); + + private static void ValidateRefs(JsonNode? node, JsonNode? root, string path) { switch (node) { @@ -17,11 +20,12 @@ public static void ValidateRefs(JsonNode? node, string path = "") var currentPath = string.IsNullOrEmpty(path) ? "$ref" : path + ".$ref"; if (refUri.StartsWith("#", StringComparison.Ordinal)) { - // local ref OK + if (!GtsJsonPointer.TryEvaluate(root, refUri, out _)) + throw new InvalidOperationException($"Invalid $ref at '{currentPath}': local reference target '{refUri}' not found."); } - else if (refUri.StartsWith("gts://", StringComparison.Ordinal)) + else if (refUri.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) { - var gtsId = refUri["gts://".Length..]; + var gtsId = GtsConstants.StripUriPrefix(refUri).Split('#')[0]; if (!GtsId.TryParse(gtsId, out _) && !GtsId.TryParsePattern(gtsId, out _)) throw new InvalidOperationException( $"Invalid $ref at '{currentPath}': '{refUri}' contains invalid GTS identifier '{gtsId}'."); @@ -31,19 +35,19 @@ public static void ValidateRefs(JsonNode? node, string path = "") $"Invalid $ref at '{currentPath}': '{refUri}' must be a local ref (starting with '#') or a GTS URI (starting with 'gts://')."); } - foreach (var (k, v) in obj) + foreach (var (key, value) in obj) { - if (k == "$ref") + if (key == "$ref") continue; - var nested = string.IsNullOrEmpty(path) ? k : path + "." + k; - ValidateRefs(v, nested); + var nested = string.IsNullOrEmpty(path) ? key : path + "." + key; + ValidateRefs(value, root, nested); } - break; - case JsonArray arr: - for (var i = 0; i < arr.Count; i++) - ValidateRefs(arr[i], $"{path}[{i}]"); + case JsonArray array: + for (var index = 0; index < array.Count; index++) + ValidateRefs(array[index], root, $"{path}[{index}]"); break; } } + } diff --git a/Gts.Store/GtsSchemaTraitsValidator.cs b/Gts.Store/GtsSchemaTraitsValidator.cs new file mode 100644 index 0000000..d55a1ba --- /dev/null +++ b/Gts.Store/GtsSchemaTraitsValidator.cs @@ -0,0 +1,317 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store.Validation; + +namespace Gts.Store; + +internal static class GtsSchemaTraitsValidator +{ + internal static IReadOnlyList Validate( + GtsId schemaId, + JsonObject schemaDocument, + Func loadSchema, + IEnumerable entities, + GtsRefValidationMode refValidationMode) + { + var errors = new List(); + var chain = BuildChain(schemaId, schemaDocument, loadSchema, errors); + if (errors.Count > 0) + return errors; + + var declarations = new List(); + var values = new JsonObject(); + foreach (var schema in chain) + { + if (HasTraitRefCycle(schema[GtsSchemaKeywords.TraitsSchema], loadSchema, new HashSet())) + errors.Add("x-gts-traits-schema contains a cyclic GTS $ref dependency"); + CollectDeclarations(schema, schema, declarations, loadSchema); + var levelValues = GtsTraitComposer.CollectLevelValues(schema); + GtsTraitComposer.MergePatch(values, levelValues); + } + + var hostDialect = NormalizeDialect(schemaDocument["$schema"]?.GetValue()); + foreach (var declaration in declarations) + { + if (FindDialectMismatch(declaration, hostDialect)) + errors.Add("x-gts-traits-schema resource uses a different JSON Schema dialect from its host type"); + if (ContainsGtsRef(declaration)) + errors.Add("x-gts-traits-schema contains a cyclic or unresolved GTS $ref"); + } + if (errors.Count > 0) + return errors; + + if (declarations.Count == 0) + { + if (values.Count > 0) + errors.Add("x-gts-traits values provided but no x-gts-traits-schema is defined in the inheritance chain"); + return errors; + } + + if (declarations.Any(node => node is JsonValue value && value.TryGetValue(out var allowed) && !allowed)) + { + if (values.Count > 0 || declarations.Any(node => node is not JsonValue)) + errors.Add("x-gts-traits-schema resolves to false and prohibits trait declarations and values"); + return errors; + } + + foreach (var declaration in declarations) + { + if (declaration is not JsonObject and not JsonValue) + errors.Add("x-gts-traits-schema must be an object subschema or boolean"); + } + if (errors.Count > 0) + return errors; + + var prior = new List(); + foreach (var declaration in declarations) + { + if (declaration is JsonObject overlay && prior.Count > 0) + { + var ancestor = prior.Count == 1 && prior[0] is JsonObject single + ? (JsonObject)single.DeepClone() + : new JsonObject { ["allOf"] = new JsonArray(prior.Select(node => node?.DeepClone()).ToArray()) }; + errors.AddRange(GtsSchemaCompatibilityService.ValidateTraitOverlay(ancestor, overlay) + .Select(error => "x-gts-traits-schema is incompatible with ancestor trait schema: " + error)); + } + prior.Add(declaration); + } + if (errors.Count > 0) + return errors; + + var effectiveSchema = GtsTraitComposer.BuildEffectiveSchema(declarations, schemaDocument["$schema"]); + GtsTraitComposer.MaterializeDefaults(effectiveSchema, values); + foreach (var declaration in declarations) + { + ValidateConstValues(declaration, values, "", errors); + ValidateValueTypes(declaration, values, "", errors); + } + if (errors.Count > 0) + return errors; + + var abstractType = schemaDocument[GtsSchemaKeywords.Abstract] is JsonValue abstractValue && + abstractValue.TryGetValue(out var isAbstract) && isAbstract; + if (abstractType) + RemoveRequired(effectiveSchema); + + try + { + var result = GtsJsonSchemaEvaluator.EvaluateInline(values, effectiveSchema); + if (!result.IsValid) + errors.AddRange(GtsJsonSchemaEvaluator.FlattenErrors(result).Select(error => "trait validation: " + error)); + errors.AddRange(GtsRefValidator.ValidateSchema(effectiveSchema, schemaId.Id, entities, refValidationMode) + .Select(error => "trait x-gts-ref: " + error)); + errors.AddRange(GtsRefValidator.ValidateInstance(values, effectiveSchema, schemaId.Id, entities, refValidationMode) + .Select(error => "trait x-gts-ref: " + error)); + } + catch (Exception exception) + { + errors.Add("trait schema validation failed: " + exception.Message); + } + + return errors; + } + + private static List BuildChain( + GtsId leafId, + JsonObject leaf, + Func load, + List errors) + { + var chain = new List { leaf }; + var current = leafId.Id; + while (GtsSchemaDerivationValidator.GetParentTypeId(current) is { } parentId) + { + if (!GtsId.TryParse(parentId, out var parsed) || parsed is null || load(parsed) is not JsonObject parent) + { + errors.Add($"Precedent schema '{parentId}' not found"); + break; + } + chain.Add(parent); + current = parentId; + } + chain.Reverse(); + return chain; + } + + private static bool HasTraitRefCycle(JsonNode? node, Func loadSchema, HashSet visiting) + { + if (node is JsonObject obj) + { + if (obj["$ref"] is JsonValue value && value.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + { + var id = GtsConstants.StripUriPrefix(reference).Split('#')[0]; + if (!visiting.Add(id)) + return true; + if (GtsId.TryParse(id, out var parsed) && parsed is not null && loadSchema(parsed) is JsonObject target && + HasTraitRefCycle(target, loadSchema, visiting)) + return true; + visiting.Remove(id); + } + return obj.Any(property => HasTraitRefCycle(property.Value, loadSchema, new HashSet(visiting))); + } + return node is JsonArray array && array.Any(child => HasTraitRefCycle(child, loadSchema, new HashSet(visiting))); + } + + private static void CollectDeclarations( + JsonObject schema, + JsonObject host, + List declarations, + Func loadSchema) + { + if (schema.TryGetPropertyValue(GtsSchemaKeywords.TraitsSchema, out var declaration)) + declarations.Add(ResolveDeclaration(declaration, host, loadSchema, new HashSet(), 0)); + if (schema["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf.OfType()) + CollectDeclarations(branch, host, declarations, loadSchema); + } + } + + private static JsonNode? ResolveDeclaration( + JsonNode? node, + JsonObject host, + Func loadSchema, + HashSet visited, + int depth) + { + if (depth >= GtsConstants.MaxNestingDepth) + return node?.DeepClone(); + if (node is JsonObject obj && obj["$ref"] is JsonValue refValue && refValue.TryGetValue(out var reference)) + { + if (reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal)) + { + var id = GtsConstants.StripUriPrefix(reference).Split('#')[0]; + if (visited.Add(id) && GtsId.TryParse(id, out var parsed) && parsed is not null && loadSchema(parsed) is JsonObject target) + { + var resolved = ResolveDeclaration(target, target, loadSchema, visited, depth + 1); + if (resolved is JsonObject resolvedObject) + { + resolvedObject.Remove("$id"); + resolvedObject.Remove("$schema"); + } + return resolved; + } + } + else if (reference.StartsWith("#/", StringComparison.Ordinal) && GtsJsonPointer.TryEvaluate(host, reference, out var local)) + return ResolveDeclaration(local, host, loadSchema, visited, depth + 1); + } + if (node is JsonObject source) + { + var clone = new JsonObject(); + foreach (var (key, value) in source) + clone[key] = ResolveDeclaration(value, host, loadSchema, visited, depth + 1); + return clone; + } + if (node is JsonArray array) + return new JsonArray(array.Select(value => ResolveDeclaration(value, host, loadSchema, visited, depth + 1)).ToArray()); + return node?.DeepClone(); + } + + + private static void ValidateValueTypes(JsonNode? schema, JsonNode? values, string path, List errors) + { + if (schema is not JsonObject obj) + return; + if (obj["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf) + ValidateValueTypes(branch, values, path, errors); + } + if (obj["properties"] is not JsonObject properties || values is not JsonObject valueObject) + return; + foreach (var (name, propertySchema) in properties) + { + if (!valueObject.TryGetPropertyValue(name, out var value) || propertySchema is not JsonObject property || property["type"] is not JsonValue typeValue || !typeValue.TryGetValue(out var type)) + continue; + var matches = type switch + { + "object" => value is JsonObject, + "array" => value is JsonArray, + "string" => value is JsonValue json && json.TryGetValue(out _), + "integer" => value is JsonValue json && json.TryGetValue(out _), + "number" => value is JsonValue json && (json.TryGetValue(out _) || json.TryGetValue(out _)), + "boolean" => value is JsonValue json && json.TryGetValue(out _), + _ => true + }; + if (!matches) + errors.Add($"trait property '{name}' is not of type '{type}'"); + ValidateValueTypes(propertySchema, value, string.IsNullOrEmpty(path) ? name : path + "." + name, errors); + } + } + + private static void ValidateConstValues(JsonNode? schema, JsonNode? values, string path, List errors) + { + if (schema is not JsonObject obj) + return; + if (obj["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf) + ValidateConstValues(branch, values, path, errors); + } + if (obj["properties"] is not JsonObject properties || values is not JsonObject valueObject) + return; + foreach (var (name, propertySchema) in properties) + { + if (!valueObject.TryGetPropertyValue(name, out var value)) + continue; + if (propertySchema is JsonObject property && property["const"] is JsonNode constant && !JsonNode.DeepEquals(constant, value)) + errors.Add($"trait property '{name}' violates const constraint"); + ValidateConstValues(propertySchema, value, string.IsNullOrEmpty(path) ? name : path + "." + name, errors); + } + } + + private static string NormalizeDialect(string? dialect) + { + if (dialect?.Contains("draft-07", StringComparison.Ordinal) == true) + return "draft-07"; + if (dialect?.Contains("2019-09", StringComparison.Ordinal) == true) + return "2019-09"; + if (dialect?.Contains("2020-12", StringComparison.Ordinal) == true) + return "2020-12"; + return dialect?.TrimEnd('#') ?? "draft-07"; + } + + private static bool ContainsGtsRef(JsonNode? node) => node switch + { + JsonObject obj => obj["$ref"] is JsonValue value && value.TryGetValue(out var reference) && reference.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal) || + obj.Any(property => ContainsGtsRef(property.Value)), + JsonArray array => array.Any(ContainsGtsRef), + _ => false + }; + + private static bool FindDialectMismatch(JsonNode? node, string hostDialect) + { + if (node is JsonObject obj) + { + if (obj["$schema"] is JsonValue value && value.TryGetValue(out var dialect) && NormalizeDialect(dialect) != hostDialect) + return true; + return obj.Any(property => FindDialectMismatch(property.Value, hostDialect)); + } + return node is JsonArray array && array.Any(child => FindDialectMismatch(child, hostDialect)); + } + + private static void RemoveRequired(JsonNode? node) + { + if (node is not JsonObject obj) + return; + obj.Remove("required"); + foreach (var keyword in new[] { "properties", "patternProperties", "definitions", "$defs", "dependentSchemas" }) + { + if (obj[keyword] is JsonObject map) + { + foreach (var child in map.Select(property => property.Value)) + RemoveRequired(child); + } + } + foreach (var keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (obj[keyword] is JsonArray array) + { + foreach (var child in array) + RemoveRequired(child); + } + } + foreach (var keyword in new[] { "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else" }) + RemoveRequired(obj[keyword]); + } +} diff --git a/Gts.Store/GtsSchemaValidationResult.cs b/Gts.Store/GtsSchemaValidationResult.cs index c35022d..caae4c1 100644 --- a/Gts.Store/GtsSchemaValidationResult.cs +++ b/Gts.Store/GtsSchemaValidationResult.cs @@ -12,7 +12,7 @@ public sealed class GtsSchemaValidationResult public string? SchemaId { get; init; } /// High-level failure code when is false; null on success. - public string? FailureReason { get; init; } + public GtsValidationFailure? FailureReason { get; init; } /// /// Detailed messages: JSON Schema evolution violations vs a precedent, or a single ref-format error. diff --git a/Gts.Store/GtsSchemaValidationService.cs b/Gts.Store/GtsSchemaValidationService.cs new file mode 100644 index 0000000..0da7a1f --- /dev/null +++ b/Gts.Store/GtsSchemaValidationService.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store.Validation; + +namespace Gts.Store; + +internal sealed class GtsSchemaValidationService(IGtsStore store) +{ + internal async ValueTask ValidateStoredAsync(string schemaTypeId, CancellationToken cancellationToken, GtsRefValidationMode refValidationMode) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(schemaTypeId)) return Failure(schemaTypeId, GtsValidationFailure.InvalidSchemaId); + var id = schemaTypeId.Trim(); + if (!GtsId.TryParse(id, out var parsed) || parsed is null || !parsed.IsType) return Failure(id, GtsValidationFailure.InvalidSchemaId); + var entity = await store.GetAsync(parsed).ConfigureAwait(false); + if (entity is null) return Failure(id, GtsValidationFailure.SchemaNotFound); + if (!entity.IsSchema) return Failure(id, GtsValidationFailure.NotASchema); + return await ValidateAsync(parsed, entity.Content, cancellationToken, refValidationMode).ConfigureAwait(false); + } + + internal async ValueTask ValidateAsync(GtsId schemaId, JsonObject document, CancellationToken cancellationToken, GtsRefValidationMode refValidationMode, string? stagingSessionId = null) + { + ArgumentNullException.ThrowIfNull(schemaId); + ArgumentNullException.ThrowIfNull(document); + cancellationToken.ThrowIfCancellationRequested(); + if (!schemaId.IsType) return Failure(schemaId.Id, GtsValidationFailure.InvalidSchemaId); + if (!document.ContainsKey("$schema") && document.ContainsKey("$$schema")) + document = GtsSchemaDocumentNormalizer.CanonicalizeKeywords(document); + + // The dialect floor is checked here too, not only at ingest, so every validation route + // (stored schema, /validate-type-schema) enforces it — mirroring the Rust reference, where + // the dialect check runs first inside the shared per-type validation. + if (!GtsTypeSchema.TryGetSupportedDialect(document, out _, out var dialectError)) + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.UnsupportedDialect, Errors = new[] { dialectError! } }; + + var keywordErrors = GtsSchemaKeywordValidator.Validate(document); + if (keywordErrors.Count > 0) + { + var jsonSchemaError = keywordErrors.Any(error => error.StartsWith("JSON Schema", StringComparison.Ordinal)); + return new GtsSchemaValidationResult + { + Ok = false, + SchemaId = schemaId.Id, + FailureReason = jsonSchemaError ? GtsValidationFailure.InvalidJsonSchema : GtsValidationFailure.InvalidGtsKeyword, + Errors = jsonSchemaError ? keywordErrors.Select(error => "JSON Schema validation failed: " + error).ToArray() : keywordErrors + }; + } + + try + { + GtsSchemaRefFormatValidator.ValidateRefs(document); + } + catch (Exception exception) + { + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.InvalidRefFormat, Errors = new[] { exception.Message } }; + } + + var entities = await store.SnapshotForReadAsync(stagingSessionId).ConfigureAwait(false); + var schemaById = new Dictionary(StringComparer.Ordinal); + foreach (var candidate in entities) + { + if (candidate.IsSchema && candidate.GtsId is not null) + schemaById[candidate.GtsId.Id] = candidate; + } + + JsonObject? Load(GtsId id) + { + if (!schemaById.TryGetValue(id.Id, out var entity) || !entity.IsSchema) + return null; + return !entity.Content.ContainsKey("$schema") && entity.Content.ContainsKey("$$schema") + ? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(entity.Content) + : entity.Content; + } + + var (derivationValid, derivationErrors) = GtsSchemaDerivationValidator.ValidateAgainstRegistry(schemaId, document, Load); + if (!derivationValid) + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.PrecedentIncompatible, Errors = new[] { "Derived schema is not compatible with base: " + string.Join("; ", derivationErrors) } }; + + var referenceErrors = GtsRefValidator.ValidateSchema(document, schemaId.Id, entities, refValidationMode); + if (referenceErrors.Count > 0) + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.GtsRefValidationFailed, Errors = referenceErrors }; + + var ancestorTypeId = GtsSchemaDerivationValidator.GetParentTypeId(schemaId.Id); + while (ancestorTypeId is not null && GtsId.TryParse(ancestorTypeId, out var ancestorId) && ancestorId is not null && Load(ancestorId) is JsonObject ancestor) + { + var ancestorErrors = GtsSchemaTraitsValidator.Validate(ancestorId, ancestor, Load, entities, refValidationMode); + if (ancestorErrors.Count > 0) + return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.TraitValidationFailed, Errors = ancestorErrors.Select(error => $"Ancestor '{ancestorTypeId}' is invalid: {error}").ToArray() }; + ancestorTypeId = GtsSchemaDerivationValidator.GetParentTypeId(ancestorTypeId); + } + + var traitErrors = GtsSchemaTraitsValidator.Validate(schemaId, document, Load, entities, refValidationMode); + return traitErrors.Count > 0 + ? new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.TraitValidationFailed, Errors = traitErrors } + : new GtsSchemaValidationResult { Ok = true, SchemaId = schemaId.Id }; + } + + private static GtsSchemaValidationResult Failure(string? id, GtsValidationFailure reason) => new() { Ok = false, SchemaId = id, FailureReason = reason }; +} \ No newline at end of file diff --git a/Gts.Store/GtsTraitComposer.cs b/Gts.Store/GtsTraitComposer.cs new file mode 100644 index 0000000..07f311c --- /dev/null +++ b/Gts.Store/GtsTraitComposer.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +internal static class GtsTraitComposer +{ + internal static JsonObject CollectLevelValues(JsonObject schema) + { + var values = new JsonObject(); + CollectValues(schema, values); + return values; + } + + internal static void MergePatch(JsonObject target, JsonObject patch) + { + foreach (var (key, value) in patch) + { + if (value is null) + target.Remove(key); + else if (value is JsonObject patchObject) + { + var targetObject = target[key] as JsonObject ?? new JsonObject(); + MergePatch(targetObject, patchObject); + target[key] = targetObject; + } + else + target[key] = value.DeepClone(); + } + } + + internal static JsonObject BuildEffectiveSchema(IReadOnlyList declarations, JsonNode? dialect) + { + var effective = declarations.Count == 1 + ? declarations[0]!.DeepClone() + : new JsonObject { ["type"] = "object", ["allOf"] = new JsonArray(declarations.Select(node => node?.DeepClone()).ToArray()) }; + var schema = effective as JsonObject ?? new JsonObject(); + if (dialect is not null) + schema["$schema"] = dialect.DeepClone(); + return schema; + } + + internal static void MaterializeDefaults(JsonObject schema, JsonObject values) + { + if (schema["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf.OfType().Reverse()) + MaterializeDefaults(branch, values); + } + if (schema["properties"] is not JsonObject properties) + return; + foreach (var (name, node) in properties) + { + if (!values.ContainsKey(name) && node is JsonObject property && property.TryGetPropertyValue("default", out var defaultValue)) + values[name] = defaultValue?.DeepClone(); + if (values[name] is JsonObject nestedValues && node is JsonObject nestedSchema) + MaterializeDefaults(nestedSchema, nestedValues); + } + } + + private static void CollectValues(JsonObject schema, JsonObject values) + { + if (schema[GtsSchemaKeywords.Traits] is JsonObject traits) + { + foreach (var (key, value) in traits) + values[key] = value?.DeepClone(); + } + if (schema["allOf"] is JsonArray allOf) + { + foreach (var branch in allOf.OfType()) + CollectValues(branch, values); + } + } +} \ No newline at end of file diff --git a/Gts.Store/GtsTypeSchema.cs b/Gts.Store/GtsTypeSchema.cs new file mode 100644 index 0000000..0620ee9 --- /dev/null +++ b/Gts.Store/GtsTypeSchema.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// +/// The canonical-identity and JSON Schema dialect rules a GTS Type Schema must satisfy +/// (README §2.4, §11.0), in one place so every ingest and validation route gives the same +/// verdict on the same document. Mirrors the single declared_type_id / document_dialect +/// entry points in the Rust reference implementation. +/// +public static class GtsTypeSchema +{ + private const string GtsUriPrefix = GtsConstants.UriPrefix; + + /// + /// Resolves the JSON Schema dialect a Type Schema declares in its top-level $schema. + /// GTS admits Draft-07, Draft 2019-09 and Draft 2020-12; Draft-07 is the floor and there is no + /// custom meta-schema. The http/https spellings and a trailing # are equivalent; + /// anything else is refused rather than read as some fallback dialect. + /// + /// The Type Schema document. + /// On success, the canonical short tag (draft-07, 2019-09, 2020-12). + /// On failure, why the document declares no supported dialect. + public static bool TryGetSupportedDialect(JsonObject schema, out string dialect, out string? error) + { + ArgumentNullException.ThrowIfNull(schema); + dialect = ""; + error = null; + if (!TryGetNonEmptyString(schema, "$schema", out var declared)) + { + error = "a GTS Type Schema must declare a top-level '$schema'"; + return false; + } + + var normalized = declared.Replace("https://", "http://", StringComparison.Ordinal).TrimEnd('#'); + switch (normalized) + { + case "http://json-schema.org/draft-07/schema": + dialect = "draft-07"; + return true; + case "http://json-schema.org/draft/2019-09/schema": + dialect = "2019-09"; + return true; + case "http://json-schema.org/draft/2020-12/schema": + dialect = "2020-12"; + return true; + default: + error = IsPreDraft07(normalized) + ? $"'$schema' declares '{declared}', but Draft-07 is the minimum supported dialect" + : $"'$schema' declares '{declared}', which is not a supported dialect " + + "(Draft-07, Draft 2019-09 or Draft 2020-12)"; + return false; + } + } + + /// + /// The GTS Type Identifier a canonical Type Schema declares: a top-level $schema and a + /// top-level $id of the form gts://<type-id> naming a GTS type. Whether the + /// dialect is supported is a validation question, not an identity one, so it is not checked here. + /// + /// The candidate Type Schema document. + /// On success, the bare GTS Type Identifier (no gts:// prefix). + /// On failure, why the document is not a canonical GTS Type Schema. + public static bool TryGetDeclaredTypeId(JsonObject schema, out string typeId, out string? error) + { + ArgumentNullException.ThrowIfNull(schema); + typeId = ""; + error = null; + if (!schema.ContainsKey("$schema")) + { + error = "a GTS Type Schema must declare a top-level '$schema'"; + return false; + } + + if (!TryGetNonEmptyString(schema, "$id", out var id) || !id.StartsWith(GtsUriPrefix, StringComparison.Ordinal)) + { + error = $"a GTS Type Schema must declare a top-level '$id' of the form '{GtsUriPrefix}'"; + return false; + } + + var candidate = id[GtsUriPrefix.Length..]; + if (!GtsId.TryParse(candidate, out var parsed) || parsed is null || !parsed.IsType) + { + error = $"'$id' '{id}' does not name a GTS Type Identifier"; + return false; + } + + typeId = candidate; + return true; + } + + private static bool IsPreDraft07(string normalizedSchemaUri) => normalizedSchemaUri is + "http://json-schema.org/draft-06/schema" or + "http://json-schema.org/draft-04/schema" or + "http://json-schema.org/draft-03/schema" or + "http://json-schema.org/draft-02/schema" or + "http://json-schema.org/draft-01/schema" or + "http://json-schema.org/draft-00/schema"; + + private static bool TryGetNonEmptyString(JsonObject obj, string name, out string value) + { + value = ""; + if (!obj.TryGetPropertyValue(name, out var node) || node is not JsonValue jsonValue || + !jsonValue.TryGetValue(out var parsed) || string.IsNullOrEmpty(parsed)) + return false; + value = parsed; + return true; + } +} diff --git a/Gts.Store/GtsValidationFailure.cs b/Gts.Store/GtsValidationFailure.cs new file mode 100644 index 0000000..cbdb67e --- /dev/null +++ b/Gts.Store/GtsValidationFailure.cs @@ -0,0 +1,57 @@ +namespace Gts.Store; + +/// +/// Typed reason a validation, cast, or registration operation failed. Replaces the previously +/// stringly-typed FailureReason fields so producers and consumers share one closed set of values +/// instead of matching on magic strings. Use to obtain the +/// external string form. +/// +public enum GtsValidationFailure +{ + // Instance validation + InstanceNotFound, + NotAnInstance, + SchemaIdMissing, + InvalidSchemaId, + SchemaNotFound, + NotASchema, + MixedDialectSchemaGraph, + TypeSchemaValidationFailed, + GtsRefValidationFailed, + SchemaValidationFailed, + + /// Instance targets an x-gts-abstract type schema, which cannot be instantiated. + AbstractTypeNotInstantiable, + + // Schema validation + InvalidJsonSchema, + InvalidGtsKeyword, + InvalidRefFormat, + PrecedentIncompatible, + TraitValidationFailed, + UnsupportedDialect, + + // Cast + InvalidInstanceId, + InvalidTargetSchemaId, + TargetSchemaNotFound, + SourceSchemaNotFound, + NotMinorVariantPair, + SchemaNormalizationFailed, + CastValidationFailed, +} + +/// Helpers for the external (wire) representation of . +public static class GtsValidationFailures +{ + /// + /// Returns the exact string historically emitted for this reason. All values map to their member + /// name except , which predates this + /// enum as a free-text message and is preserved verbatim so responses do not change. + /// + public static string ToWire(this GtsValidationFailure failure) => failure switch + { + GtsValidationFailure.AbstractTypeNotInstantiable => "Cannot instantiate abstract GTS Type Schema", + _ => failure.ToString(), + }; +} diff --git a/Gts.Store/IGtsStore.cs b/Gts.Store/IGtsStore.cs index dfd8aed..ca3b054 100644 --- a/Gts.Store/IGtsStore.cs +++ b/Gts.Store/IGtsStore.cs @@ -2,12 +2,32 @@ namespace Gts.Store; +/// Outcome of an atomic conditional save (). +public enum GtsSaveOutcome +{ + /// The entity was inserted (no prior entity with the same id). + Added, + + /// An entity with the same id already existed with identical content; the store was left unchanged. + Unchanged, + + /// An entity with the same id already existed with different content; the store was left unchanged. + Conflict +} + /// Storage abstraction for GTS JSON entities keyed by GTS ID. public interface IGtsStore { /// Stores or overwrites the entity (keyed by its GTS ID). ValueTask SaveAsync(GtsJsonEntity entity); - + + /// + /// Atomically stores the entity unless an entity with the same id already exists with different content. + /// This is a single compare-and-swap that closes the check-then-save race: concurrent callers writing + /// different content for the same id cannot both succeed. + /// + ValueTask TrySaveAsync(GtsJsonEntity entity); + /// Retrieves an entity by GTS ID, or null if not found. ValueTask GetAsync(GtsId id); @@ -15,12 +35,71 @@ public interface IGtsStore /// Looks up an instance by GTS instance id or by an opaque id (e.g. UUID for anonymous instances). /// ValueTask GetByInstanceIdAsync(string instanceId); - + /// Returns all stored entities, including instances without a (e.g. opaque ids). ValueTask> GetAllAsync(); - + + /// + /// Returns a read-only snapshot of all stored entities without deep-cloning their content, + /// for internal read-only consumers (e.g. validation). Committed entities are overlaid with the staging + /// entries of the single staging session named by (see + /// ) so a schema being validated as part of that batch can resolve its + /// not-yet-committed siblings. When is null the overlay is empty + /// and the snapshot is committed-only, so single-entity/standalone validation never observes another + /// request's unvalidated staged entries. The returned entities and their + /// must not be mutated. Prefer this over on hot paths that only read. + /// + ValueTask> SnapshotForReadAsync(string? stagingSessionId = null); + + /// + /// Like but committed-only: the staging overlay is never + /// included. This is the read path for public/API consumers (e.g. /query), so a + /// staged-but-not-yet-committed entity is never exposed. + /// + ValueTask> SnapshotCommittedForReadAsync(); + + /// + /// Stages an entity WITHOUT publishing it, returning a unique staging token that + /// identifies this staged entry (never its registry key). The entry is associated with the staging + /// (a batch id); when omitted, the entry gets its own private session + /// so it is visible to no other snapshot. A staged entity is visible to internal validation only via + /// called with the same session id (so a batch resolves its own + /// intra-batch references regardless of order, and never sees another request's staged entries) and is + /// invisible to public reads (, , + /// , ) until it is committed. + /// Because each stage yields its own token, two entries that resolve to the same id - whether from a + /// duplicated batch entry or two concurrent batches - never clobber each other's staged content, and a + /// commit/discard only ever affects the entry it names. Callers MUST eventually commit or discard the token. + /// + ValueTask StageAsync(GtsJsonEntity entity, string? stagingSessionId = null); + + /// + /// Publishes a previously staged entity by its staging , making it visible to + /// public reads. The publish is atomic with a conflict check against the committed store (the same + /// compare-and-swap as ): when it was + /// inserted, when an identical entity already held the id, and + /// when a different entity already held the id (nothing is + /// published in that case). An unknown/already-resolved token yields . + /// + ValueTask CommitStagedAsync(string token); + + /// + /// Atomically publishes a whole set of staged as one all-or-nothing unit: every + /// token's target is checked against the committed store (and against its batch siblings) under a single + /// lock, and if any target would conflict - a concurrent commit won the id with different content, + /// an intra-batch duplicate disagrees, or a token is unknown - nothing is published. This + /// closes the gap where a per-entry commit loop could publish a dependent schema against a parent or + /// $ref target whose content changed after the dependent was validated. Returns one + /// per token, positionally aligned with ; on a batch + /// conflict the non-conflicting entries report as well because the + /// batch as a whole was not published. Committed tokens are removed from the staging area; on conflict the + /// tokens remain staged for the caller to discard. + /// + ValueTask> CommitStagedBatchAsync(IReadOnlyList tokens); + + /// Drops a staged entity by its staging token. The committed state is untouched. + ValueTask DiscardStagedAsync(string token); + /// Returns the number of stored entities. ValueTask CountAsync(); - - //ValueTask ValidateAsync(); // TODO: } diff --git a/Gts.Store/InMemory/InMemoryGtsRegistry.cs b/Gts.Store/InMemory/InMemoryGtsRegistry.cs index 84b771d..e10a563 100644 --- a/Gts.Store/InMemory/InMemoryGtsRegistry.cs +++ b/Gts.Store/InMemory/InMemoryGtsRegistry.cs @@ -1,27 +1,18 @@ -using System.Collections.Concurrent; using Gts.Extraction; namespace Gts.Store.InMemory; -/// In-memory implementation of . -internal class InMemoryGtsRegistry : GtsRegistry +/// In-memory implementation of backed by a thread-safe store. +internal sealed class InMemoryGtsRegistry : GtsRegistry { private InMemoryGtsRegistry(IGtsStore store, GtsRegistryConfig config) : base(store, config) { } - /// Creates a simple (non-concurrent) in-memory registry. - internal static InMemoryGtsRegistry Simple(GtsRegistryConfig config) + /// Creates an in-memory registry backed by the thread-safe in-memory store. + internal static InMemoryGtsRegistry Create(GtsRegistryConfig config) { - return new InMemoryGtsRegistry( - new InMemoryGtsStore>(), config); - } - - /// Creates a thread-safe in-memory registry using a concurrent dictionary. - internal static InMemoryGtsRegistry Concurrent(GtsRegistryConfig config) - { - return new InMemoryGtsRegistry( - new InMemoryGtsStore>(), config); + return new InMemoryGtsRegistry(new InMemoryGtsStore(), config); } } diff --git a/Gts.Store/InMemory/InMemoryGtsStore.cs b/Gts.Store/InMemory/InMemoryGtsStore.cs index 1630a92..a5de363 100644 --- a/Gts.Store/InMemory/InMemoryGtsStore.cs +++ b/Gts.Store/InMemory/InMemoryGtsStore.cs @@ -1,44 +1,94 @@ -using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text.Json.Nodes; using Gts.Extraction; namespace Gts.Store.InMemory; -/// In-memory implementation of using a dictionary-like backing store. -internal class InMemoryGtsStore : IGtsStore - where T : class, IDictionary, new() +/// +/// Thread-safe in-memory implementation of . All access is serialized through a single +/// lock, so plain dictionaries are sufficient (a concurrent collection would add overhead without changing behavior). +/// +internal sealed class InMemoryGtsStore : IGtsStore { - private readonly T _entities = new(); - private readonly ConcurrentDictionary _instanceKeys = new(StringComparer.Ordinal); + private readonly Dictionary _entities = new(); + private readonly Dictionary _instanceKeys = new(StringComparer.Ordinal); + // Staging overlay: entities registered but not yet committed. Visible to internal validation + // (via SnapshotForReadAsync for the SAME session) so a batch resolves its own intra-batch references + // regardless of order, but invisible both to public reads and to other sessions' validation until + // CommitStagedAsync/CommitStagedBatchAsync. Keyed by a unique staging TOKEN (not the resolved instance + // key) so two entries that resolve to the same id - a duplicated batch entry or two concurrent batches - + // never clobber each other, and commit/discard only affect their own entry. Each entry also records the + // staging SESSION it belongs to so one request's unvalidated entries never leak into another's snapshot. + private readonly Dictionary _staged = new(StringComparer.Ordinal); private readonly object _sync = new(); + private readonly record struct StagedEntry(string Key, GtsJsonEntity Entity, string Session); + /// public ValueTask SaveAsync(GtsJsonEntity entity) { ArgumentNullException.ThrowIfNull(entity); + var key = ResolveKey(entity); + var stored = entity.DeepClone(); + lock (_sync) + { + StoreLocked(stored, key); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask TrySaveAsync(GtsJsonEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + + var key = ResolveKey(entity); + var stored = entity.DeepClone(); + lock (_sync) + { + var existing = FindLocked(stored.GtsId, key); + if (existing is not null) + return ValueTask.FromResult( + JsonNode.DeepEquals(existing.Content, stored.Content) + ? GtsSaveOutcome.Unchanged + : GtsSaveOutcome.Conflict); + + StoreLocked(stored, key); + return ValueTask.FromResult(GtsSaveOutcome.Added); + } + } + + private static string ResolveKey(GtsJsonEntity entity) + { var extract = GtsJsonEntity.ExtractId(entity.Content); if (string.IsNullOrEmpty(extract.Id)) throw new ArgumentException("Entity must have a resolvable id (GTS id or opaque id such as a UUID).", nameof(entity)); + return extract.Id; + } - var stored = entity.DeepClone(); - lock (_sync) + private GtsJsonEntity? FindLocked(GtsId? gtsId, string instanceKey) + { + if (gtsId is not null && _entities.TryGetValue(gtsId, out var byGts)) + return byGts; + return _instanceKeys.TryGetValue(instanceKey, out var byKey) ? byKey : null; + } + + private void StoreLocked(GtsJsonEntity stored, string instanceKey) + { + if (stored.GtsId is not null) { - if (stored.GtsId is not null) + if (_entities.TryGetValue(stored.GtsId, out var previous)) { - if (_entities.TryGetValue(stored.GtsId, out var previous)) - { - var previousId = GtsJsonEntity.ExtractId(previous.Content).Id; - if (!string.IsNullOrEmpty(previousId) && previousId != extract.Id) - _instanceKeys.TryRemove(previousId, out _); - } - _entities[stored.GtsId] = stored; + var previousId = GtsJsonEntity.ExtractId(previous.Content).Id; + if (!string.IsNullOrEmpty(previousId) && previousId != instanceKey) + _instanceKeys.Remove(previousId); } - - _instanceKeys[extract.Id] = stored; + _entities[stored.GtsId] = stored; } - return ValueTask.CompletedTask; + _instanceKeys[instanceKey] = stored; } /// @@ -85,6 +135,170 @@ public ValueTask> GetAllAsync() } } + /// + public ValueTask> SnapshotForReadAsync(string? stagingSessionId = null) + { + lock (_sync) + { + // Committed entities overlaid ONLY with the staging entries of the requested session (staged wins + // for the same key), so a schema being validated as part of a batch resolves its own staged + // siblings - and only its own. A null session means committed-only, so single-entity/standalone + // validation never observes another request's unvalidated staged entries. + var byKey = new Dictionary(_instanceKeys, StringComparer.Ordinal); + if (stagingSessionId is not null) + { + foreach (var entry in _staged.Values) + { + if (entry.Session == stagingSessionId) + byKey[entry.Key] = entry.Entity; + } + } + + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var list = new List(); + foreach (var e in byKey.Values) + { + if (seen.Add(e)) + list.Add(e); + } + + return ValueTask.FromResult>(list); + } + } + + /// + public ValueTask> SnapshotCommittedForReadAsync() + { + lock (_sync) + { + var seen = new HashSet(ReferenceEqualityComparer.Instance); + var list = new List(); + foreach (var e in _instanceKeys.Values) + { + if (seen.Add(e)) + list.Add(e); + } + + return ValueTask.FromResult>(list); + } + } + + /// + public ValueTask StageAsync(GtsJsonEntity entity, string? stagingSessionId = null) + { + ArgumentNullException.ThrowIfNull(entity); + var key = ResolveKey(entity); + var stored = entity.DeepClone(); + var token = Guid.NewGuid().ToString("N"); + // No session id => a private per-entry session, so the entry is visible to no snapshot. + var session = stagingSessionId ?? token; + lock (_sync) + { + _staged[token] = new StagedEntry(key, stored, session); + } + + return ValueTask.FromResult(token); + } + + /// + public ValueTask CommitStagedAsync(string token) + { + lock (_sync) + { + if (!_staged.Remove(token, out var entry)) + return ValueTask.FromResult(GtsSaveOutcome.Conflict); + + // Atomically re-check against the committed store (the same compare-and-swap as + // TrySaveAsync) so a concurrent commit of the same id, or an intra-batch duplicate, + // cannot silently overwrite already-committed content. + var existing = FindLocked(entry.Entity.GtsId, entry.Key); + if (existing is not null) + return ValueTask.FromResult( + JsonNode.DeepEquals(existing.Content, entry.Entity.Content) + ? GtsSaveOutcome.Unchanged + : GtsSaveOutcome.Conflict); + + StoreLocked(entry.Entity, entry.Key); + return ValueTask.FromResult(GtsSaveOutcome.Added); + } + } + + /// + public ValueTask> CommitStagedBatchAsync(IReadOnlyList tokens) + { + ArgumentNullException.ThrowIfNull(tokens); + var outcomes = new GtsSaveOutcome[tokens.Count]; + lock (_sync) + { + // Phase 1: resolve every token and decide its outcome WITHOUT mutating the store. A commit-time + // conflict is: an unknown token, a committed entity holding the id with different content, or an + // intra-batch sibling already claiming the same key with different content. `pendingByKey` + // simulates the not-yet-applied writes so duplicates within the batch are compared consistently. + var entries = new StagedEntry?[tokens.Count]; + var pendingByKey = new Dictionary(StringComparer.Ordinal); + var anyConflict = false; + for (var i = 0; i < tokens.Count; i++) + { + if (!_staged.TryGetValue(tokens[i], out var entry)) + { + outcomes[i] = GtsSaveOutcome.Conflict; + anyConflict = true; + continue; + } + + entries[i] = entry; + var existing = pendingByKey.TryGetValue(entry.Key, out var pending) + ? pending + : FindLocked(entry.Entity.GtsId, entry.Key); + if (existing is not null) + { + var identical = JsonNode.DeepEquals(existing.Content, entry.Entity.Content); + outcomes[i] = identical ? GtsSaveOutcome.Unchanged : GtsSaveOutcome.Conflict; + if (!identical) + anyConflict = true; + } + else + { + outcomes[i] = GtsSaveOutcome.Added; + } + + pendingByKey[entry.Key] = entry.Entity; + } + + // All-or-nothing: if any target conflicts, publish none and report every entry as a conflict + // (the batch as a whole did not land). The tokens stay staged for the caller to discard. + if (anyConflict) + { + for (var i = 0; i < tokens.Count; i++) + outcomes[i] = GtsSaveOutcome.Conflict; + return ValueTask.FromResult>(outcomes); + } + + // Phase 2: publish. Every survivor was validated against the same session snapshot and no target + // conflicts, so the whole dependency set lands atomically under this single lock. + for (var i = 0; i < tokens.Count; i++) + { + var entry = entries[i]!.Value; + _staged.Remove(tokens[i]); + if (outcomes[i] == GtsSaveOutcome.Added) + StoreLocked(entry.Entity, entry.Key); + } + + return ValueTask.FromResult>(outcomes); + } + } + + /// + public ValueTask DiscardStagedAsync(string token) + { + lock (_sync) + { + _staged.Remove(token); + } + + return ValueTask.CompletedTask; + } + /// public ValueTask CountAsync() { diff --git a/Gts.Store/Properties/AssemblyInfo.cs b/Gts.Store/Properties/AssemblyInfo.cs index f22a328..418f537 100644 --- a/Gts.Store/Properties/AssemblyInfo.cs +++ b/Gts.Store/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Gts.Server")] +[assembly: InternalsVisibleTo("Gts.Tests")] diff --git a/Gts.Store/Validation/GtsFormatRegistry.cs b/Gts.Store/Validation/GtsFormatRegistry.cs new file mode 100644 index 0000000..ba9fe77 --- /dev/null +++ b/Gts.Store/Validation/GtsFormatRegistry.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Net; +using System.Net.Mail; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.RegularExpressions; +using Json.Schema; + +namespace Gts.Store.Validation; + +internal static class GtsFormatRegistry +{ + internal static FormatRegistry Create() + { + var registry = new FormatRegistry(); + registry.Register(new PredicateFormat("uuid", value => Guid.TryParseExact(value, "D", out _))); + registry.Register(new PredicateFormat("email", IsEmail)); + registry.Register(new PredicateFormat("date-time", IsDateTime)); + registry.Register(new PredicateFormat("date", value => DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))); + registry.Register(new PredicateFormat("time", IsTime)); + registry.Register(new PredicateFormat("uri", value => Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Scheme))); + registry.Register(new PredicateFormat("hostname", value => Uri.CheckHostName(value) == UriHostNameType.Dns)); + registry.Register(new PredicateFormat("ipv4", value => IPAddress.TryParse(value, out var address) && address.AddressFamily == AddressFamily.InterNetwork)); + registry.Register(new PredicateFormat("ipv6", value => IPAddress.TryParse(value, out var address) && address.AddressFamily == AddressFamily.InterNetworkV6)); + registry.Register(new PredicateFormat("regex", IsRegex)); + return registry; + } + + private static bool IsEmail(string value) + { + try + { + var address = new MailAddress(value); + return address.Address == value && value.Contains('@'); + } + catch + { + return false; + } + } + + private static bool IsDateTime(string value) => + Regex.IsMatch(value, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$") && + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + + private static bool IsTime(string value) + { + var match = Regex.Match(value, @"^(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$"); + if (!match.Success || int.Parse(match.Groups[1].Value) > 23 || int.Parse(match.Groups[2].Value) > 59 || int.Parse(match.Groups[3].Value) > 59) + return false; + return !match.Groups[5].Success || int.Parse(match.Groups[5].Value) <= 23 && int.Parse(match.Groups[6].Value) <= 59; + } + + private static bool IsRegex(string value) + { + try + { + _ = new Regex(value, RegexOptions.ECMAScript); + return true; + } + catch + { + return false; + } + } + + private sealed class PredicateFormat(string key, Func predicate) : Format(key) + { + public override bool Validate(JsonElement value, out string? errorMessage) + { + var valid = value.ValueKind != JsonValueKind.String || predicate(value.GetString()!); + errorMessage = valid ? null : $"Value is not a valid '{Key}' format"; + return valid; + } + } +} \ No newline at end of file diff --git a/Gts.Store/Validation/GtsJson.cs b/Gts.Store/Validation/GtsJson.cs new file mode 100644 index 0000000..e8b8a2b --- /dev/null +++ b/Gts.Store/Validation/GtsJson.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Gts.Store.Validation; + +internal static class GtsJson +{ + internal static JsonElement ToElement(JsonNode? node) => JsonSerializer.SerializeToElement(node); + + internal static JsonObject CloneObject(JsonObject source) => (JsonObject)source.DeepClone(); +} \ No newline at end of file diff --git a/Gts.Store/Validation/GtsJsonPointer.cs b/Gts.Store/Validation/GtsJsonPointer.cs new file mode 100644 index 0000000..cfebb13 --- /dev/null +++ b/Gts.Store/Validation/GtsJsonPointer.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; +using Json.Pointer; + +namespace Gts.Store.Validation; + +internal static class GtsJsonPointer +{ + internal static bool TryEvaluate(JsonNode? root, string reference, out JsonNode? result) + { + result = null; + if (root is null || reference == "#") + { + result = root; + return root is not null; + } + + var pointerText = reference.StartsWith('#') ? reference[1..] : reference; + return JsonPointer.TryParse(pointerText, out var pointer) && pointer.TryEvaluate(root, out result); + } +} \ No newline at end of file diff --git a/Gts.Store/Validation/GtsJsonSchemaEngine.cs b/Gts.Store/Validation/GtsJsonSchemaEngine.cs new file mode 100644 index 0000000..0e6af27 --- /dev/null +++ b/Gts.Store/Validation/GtsJsonSchemaEngine.cs @@ -0,0 +1,173 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Json.Schema; + +namespace Gts.Store.Validation; + +internal sealed class GtsJsonSchemaEngine : IGtsJsonSchemaEngine +{ + internal static GtsJsonSchemaEngine Default { get; } = new(); + + private readonly ConcurrentDictionary _compiled = new(StringComparer.Ordinal); + private readonly FormatRegistry _formats = GtsFormatRegistry.Create(); + + public EvaluationResults Evaluate(JsonNode? instance, GtsId rootSchemaId, IReadOnlyDictionary schemas) + { + if (!schemas.TryGetValue(rootSchemaId, out var root)) + throw new InvalidOperationException("Root schema is missing from the schema map."); + + // Fingerprint only the schemas actually reachable from the root via $ref, not the entire + // registry. This keeps the cache key (and the work to compute it) proportional to the schema + // graph being compiled instead of the store size, and means an unrelated schema change no + // longer needlessly invalidates this entry — any change that alters reachability is itself a + // change to a reachable document, so the key still moves when it must. + var reachable = ReachableClosure(rootSchemaId, root, schemas); + var fingerprint = Fingerprint(rootSchemaId.Id, reachable.Select(item => item.Value)); + // Compile against only the reachable closure, not the whole registry: each cached + // JsonSchema keeps its SchemaRegistry (and the dictionary captured by registry.Fetch) + // alive, so capturing the full store would retain O(cache size × store size) of JSON. + var closure = reachable.ToDictionary(item => item.Key, item => item.Value); + var schema = GetOrCompile(fingerprint, () => Compile(rootSchemaId, root, closure)); + return schema.Evaluate(GtsJson.ToElement(instance), EvaluationOptions()); + } + + public EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDocument) + { + var normalized = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(schemaDocument); + var fingerprint = Fingerprint("inline", new[] { normalized }); + var schema = GetOrCompile(fingerprint, () => JsonSchema.FromText(normalized.ToJsonString(), BuildOptions(normalized, new SchemaRegistry()))); + return schema.Evaluate(GtsJson.ToElement(instance), EvaluationOptions()); + } + + public IReadOnlyList FlattenErrors(EvaluationResults results) + { + var errors = new List(); + Walk(results, errors); + return errors; + } + + private JsonSchema GetOrCompile(string fingerprint, Func compile) + { + if (_compiled.Count >= 512) + _compiled.Clear(); + return _compiled.GetOrAdd(fingerprint, _ => compile()); + } + + private JsonSchema Compile(GtsId rootId, JsonObject root, IReadOnlyDictionary schemas) + { + var registry = new SchemaRegistry(); + var options = BuildOptions(root, registry); + registry.Fetch = (uri, _) => + { + if (!GtsSchemaResolutionUris.TryGetGtsId(uri, out var id) || !GtsId.TryParse(id, out var parsed) || parsed is null || !schemas.TryGetValue(parsed, out var document)) + return null; + return JsonSchema.FromText(document.ToJsonString(), options, uri); + }; + return JsonSchema.FromText(root.ToJsonString(), options, GtsSchemaResolutionUris.ToSyntheticUri(rootId.Id)); + } + + private static BuildOptions BuildOptions(JsonObject schema, SchemaRegistry registry) => new() + { + Dialect = DialectFor(schema), + SchemaRegistry = registry, + DialectRegistry = new DialectRegistry(), + VocabularyRegistry = new VocabularyRegistry() + }; + + private EvaluationOptions EvaluationOptions() => new() + { + RequireFormatValidation = true, + OutputFormat = OutputFormat.List, + FormatRegistry = _formats + }; + + private static Dialect DialectFor(JsonObject schema) + { + var uri = schema["$schema"] is JsonValue value && value.TryGetValue(out var s) ? s : null; + if (uri?.Contains("2020-12", StringComparison.Ordinal) == true) + return Dialect.Draft202012; + if (uri?.Contains("2019-09", StringComparison.Ordinal) == true) + return Dialect.Draft201909; + return Dialect.Draft07; + } + + private static string Fingerprint(string root, IEnumerable schemas) + { + var builder = new StringBuilder(root); + foreach (var schema in schemas) + builder.Append('\n').Append(schema.ToJsonString()); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()))); + } + + /// + /// Returns the root schema plus every schema transitively reachable from it via $ref, + /// ordered by id for a stable fingerprint. Cycle-safe (each id is visited once). + /// + private static List> ReachableClosure( + GtsId rootId, JsonObject root, IReadOnlyDictionary schemas) + { + var reachable = new List> { new(rootId, root) }; + var visited = new HashSet(StringComparer.Ordinal) { rootId.Id }; + var queue = new Queue(); + queue.Enqueue(root); + while (queue.Count > 0) + { + foreach (var refUri in CollectRefUris(queue.Dequeue(), 0)) + { + var basePart = refUri.Split('#')[0]; + if (!Uri.TryCreate(basePart, UriKind.Absolute, out var uri) || + !GtsSchemaResolutionUris.TryGetGtsId(uri, out var id) || + !GtsId.TryParse(id, out var parsed) || parsed is null || + !visited.Add(parsed.Id) || + !schemas.TryGetValue(parsed, out var target)) + continue; + reachable.Add(new(parsed, target)); + queue.Enqueue(target); + } + } + reachable.Sort((a, b) => string.CompareOrdinal(a.Key.Id, b.Key.Id)); + return reachable; + } + + private static IEnumerable CollectRefUris(JsonNode? node, int depth) + { + if (depth >= GtsConstants.MaxNestingDepth) + yield break; + switch (node) + { + case JsonObject obj: + foreach (var (key, value) in obj) + { + if (key == "$ref" && value is JsonValue rv && rv.TryGetValue(out var reference)) + yield return reference; + else + foreach (var nested in CollectRefUris(value, depth + 1)) + yield return nested; + } + break; + case JsonArray array: + foreach (var child in array) + foreach (var nested in CollectRefUris(child, depth + 1)) + yield return nested; + break; + } + } + + private static void Walk(EvaluationResults node, List errors) + { + if (!node.IsValid) + { + if (node.Errors is { Count: > 0 }) + { + foreach (var error in node.Errors) + errors.Add($"{node.InstanceLocation}: {error}"); + } + else if (node.Details is not { Count: > 0 }) + errors.Add($"{node.InstanceLocation} @ {node.EvaluationPath}"); + } + foreach (var detail in node.Details ?? []) + Walk(detail, errors); + } +} \ No newline at end of file diff --git a/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs b/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs index 13f8704..bc26b3d 100644 --- a/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs +++ b/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using System.Text.Json.Nodes; using Json.Schema; @@ -6,65 +5,16 @@ namespace Gts.Store.Validation; internal static class GtsJsonSchemaEvaluator { + private static readonly IGtsJsonSchemaEngine Engine = GtsJsonSchemaEngine.Default; + internal static EvaluationResults Evaluate( - JsonElement instance, + JsonNode? instance, GtsId rootSchemaId, - IReadOnlyDictionary normalizedSchemasById) - { - var registry = new SchemaRegistry(); - var buildOptions = new BuildOptions - { - Dialect = Dialect.Draft07, - SchemaRegistry = registry - }; - - registry.Fetch = (uri, _) => - { - if (!GtsSchemaResolutionUris.TryGetGtsId(uri, out var idStr)) - return null; - if (!GtsId.TryParse(idStr, out var gid) || gid is null) - return null; - if (!normalizedSchemasById.TryGetValue(gid, out var doc)) - return null; - return JsonSchema.FromText(doc.ToJsonString(), buildOptions, uri); - }; - - var rootUri = GtsSchemaResolutionUris.ToSyntheticUri(rootSchemaId.Id); - if (!normalizedSchemasById.TryGetValue(rootSchemaId, out var rootDoc)) - throw new InvalidOperationException("Root schema is missing from the normalized map."); - - var schema = JsonSchema.FromText(rootDoc.ToJsonString(), buildOptions, rootUri); - - var evalOptions = new EvaluationOptions - { - RequireFormatValidation = true, - OutputFormat = OutputFormat.List - }; - - return schema.Evaluate(instance, evalOptions); - } - - internal static IReadOnlyList FlattenErrors(EvaluationResults results) - { - var list = new List(); - Walk(results, list); - return list; - } + IReadOnlyDictionary normalizedSchemasById) => + Engine.Evaluate(instance, rootSchemaId, normalizedSchemasById); - private static void Walk(EvaluationResults node, List sink) - { - if (!node.IsValid) - { - if (node.Errors is { Count: > 0 }) - { - foreach (var err in node.Errors) - sink.Add($"{node.InstanceLocation}: {err}"); - } - else if (node.Details is not { Count: > 0 }) - sink.Add($"{node.InstanceLocation} @ {node.EvaluationPath}"); - } + internal static EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDocument) => + Engine.EvaluateInline(instance, schemaDocument); - foreach (var d in node.Details ?? []) - Walk(d, sink); - } -} + internal static IReadOnlyList FlattenErrors(EvaluationResults results) => Engine.FlattenErrors(results); +} \ No newline at end of file diff --git a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs index ab653e8..4548f94 100644 --- a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs +++ b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs @@ -8,13 +8,22 @@ namespace Gts.Store.Validation; /// internal static class GtsSchemaDocumentNormalizer { - private const string GtsUriPrefix = "gts://"; + private const string GtsUriPrefix = GtsConstants.UriPrefix; - internal static JsonObject ForJsonSchemaEvaluation(JsonObject root) + internal static JsonObject CanonicalizeKeywords(JsonObject root) { - var clone = JsonNode.Parse(root.ToJsonString())!.AsObject(); + var clone = (JsonObject)root.DeepClone(); RenameDoubleDollarKeysDeep(clone); + return clone; + } + + internal static JsonObject ForJsonSchemaEvaluation(JsonObject root) + { + var clone = (JsonObject)root.DeepClone(); + if (!clone.ContainsKey("$schema")) + RenameDoubleDollarKeysDeep(clone); RewriteGtsUrisDeep(clone); + StripXGtsRefDeep(clone); return clone; } @@ -23,30 +32,80 @@ private static void RenameDoubleDollarKeysDeep(JsonNode? node) switch (node) { case JsonObject obj: - { - var keys = obj.Select(p => p.Key).ToList(); - foreach (var key in keys) { - if (key.StartsWith("$$", StringComparison.Ordinal) && key.Length > 2) + var keys = obj.Select(p => p.Key).ToList(); + foreach (var key in keys) { - var newKey = "$" + key[2..]; - var n = obj[key]!; - obj.Remove(key); - obj[newKey] = n; + if (key.StartsWith("$$", StringComparison.Ordinal) && key.Length > 2) + { + var newKey = "$" + key[2..]; + var n = obj[key]!; + obj.Remove(key); + obj[newKey] = n; + } } + + foreach (var p in obj) + RenameDoubleDollarKeysDeep(p.Value); + break; } + case JsonArray arr: + { + foreach (var item in arr) + RenameDoubleDollarKeysDeep(item); + break; + } + } + } - foreach (var p in obj) - RenameDoubleDollarKeysDeep(p.Value); - break; + private static void StripXGtsRefDeep(JsonNode? node) + { + if (node is not JsonObject obj) + return; + obj.Remove(GtsSchemaKeywords.Ref); + foreach (var keyword in new[] { "properties", "patternProperties", "definitions", "$defs", "dependentSchemas" }) + { + if (obj[keyword] is JsonObject map) + { + foreach (var child in map.Select(property => property.Value)) + StripXGtsRefDeep(child); } - case JsonArray arr: + } + foreach (var keyword in new[] { "oneOf", "anyOf", "allOf", "prefixItems" }) + { + if (obj[keyword] is JsonArray branches) { - foreach (var item in arr) - RenameDoubleDollarKeysDeep(item); - break; + // The JSON Schema engine cannot see x-gts-ref (it is stripped here and checked + // separately by GtsRefValidator). Two oneOf branches that differ only by x-gts-ref + // — e.g. {"type":"string","x-gts-ref":"a.*"} and {…"b.*"} — collapse to the same + // structural schema, so every value matches both and oneOf rejects everything. + // Evaluate such a oneOf as anyOf: exclusivity is enforced by GtsRefValidator. + // + // NOTE: gts-go and gts-rust avoid this rewrite by registering x-gts-ref as a real + // JSON Schema keyword/vocabulary, so the engine evaluates it during combinator + // resolution and oneOf "just works". JsonSchema.Net (this project's engine) exposes + // no public API to add a keyword to a built-in dialect — Dialect can only be built + // from a full IKeywordHandler list, and the built-in Draft-07/2019-09/2020-12 sets + // are not publicly enumerable — so we cannot follow that approach without reflecting + // into library internals. This localized normalization is the pragmatic alternative; + // gts-python and gts-ts use the keyword-registration approach their libraries support. + var hadRef = branches.OfType().Any(branch => branch.ContainsKey(GtsSchemaKeywords.Ref)); + foreach (var child in branches) + StripXGtsRefDeep(child); + if (branches.Count > 0 && branches.All(branch => branch is JsonObject branchObject && branchObject.Count == 0)) + obj.Remove(keyword); + else if (keyword == "oneOf" && hadRef && !obj.ContainsKey("anyOf")) + { + obj.Remove("oneOf"); + obj["anyOf"] = branches; + } } } + foreach (var keyword in new[] { "items", "additionalItems", "additionalProperties", "contains", "not", "if", "then", "else" }) + { + if (obj[keyword] is JsonObject child) + StripXGtsRefDeep(child); + } } private static void RewriteGtsUrisDeep(JsonNode? node) @@ -54,41 +113,41 @@ private static void RewriteGtsUrisDeep(JsonNode? node) switch (node) { case JsonObject obj: - { - foreach (var (key, val) in obj.ToList()) { - if (key is "$id" or "$ref" && val is JsonValue jv) + foreach (var (key, val) in obj.ToList()) { - var s = jv.GetValue(); - if (!string.IsNullOrEmpty(s)) + if (key is "$id" or "$ref" && val is JsonValue jv) { - var t = s.Trim(); - if (t.StartsWith(GtsUriPrefix, StringComparison.Ordinal)) + var s = jv.GetValue(); + if (!string.IsNullOrEmpty(s)) { - var id = t[GtsUriPrefix.Length..]; - if (id.Length > 0) - obj[key] = GtsSchemaResolutionUris.ToSyntheticUri(id).AbsoluteUri; + var t = s.Trim(); + if (t.StartsWith(GtsUriPrefix, StringComparison.Ordinal)) + { + var id = t[GtsUriPrefix.Length..]; + if (id.Length > 0) + obj[key] = GtsSchemaResolutionUris.ToSyntheticUri(id).AbsoluteUri; + } } } + else if (key == "$schema" && val is JsonValue schemaValue) + { + var dialect = schemaValue.GetValue()?.Trim(); + if (dialect is "https://json-schema.org/draft-07/schema" or "https://json-schema.org/draft-07/schema#") + obj[key] = "http://json-schema.org/draft-07/schema#"; + } } - else if (key == "$schema" && val is JsonValue schemaValue) - { - var dialect = schemaValue.GetValue()?.Trim(); - if (dialect is "https://json-schema.org/draft-07/schema" or "https://json-schema.org/draft-07/schema#") - obj[key] = "http://json-schema.org/draft-07/schema#"; - } - } - foreach (var p in obj) - RewriteGtsUrisDeep(p.Value); - break; - } + foreach (var p in obj) + RewriteGtsUrisDeep(p.Value); + break; + } case JsonArray arr: - { - foreach (var item in arr) - RewriteGtsUrisDeep(item); - break; - } + { + foreach (var item in arr) + RewriteGtsUrisDeep(item); + break; + } } } } diff --git a/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs b/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs index b38e13c..e269a80 100644 --- a/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs +++ b/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs @@ -12,7 +12,7 @@ internal static class GtsSchemaMinorVersionCanonicalizer internal static JsonObject PrepareForComparison(JsonObject root) { var normalized = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(root); - var clone = JsonNode.Parse(normalized.ToJsonString())!.AsObject(); + var clone = (JsonObject)normalized.DeepClone(); RewriteGtsIdentifiersDeep(clone); return clone; } @@ -22,27 +22,27 @@ private static void RewriteGtsIdentifiersDeep(JsonNode? node) switch (node) { case JsonObject obj: - { - foreach (var (key, val) in obj.ToList()) { - if (key is "$id" or "$ref" && val is JsonValue jv && jv.TryGetValue(out var s) - && !string.IsNullOrEmpty(s)) + foreach (var (key, val) in obj.ToList()) { - if (TryCanonicalizeUriString(s.Trim(), out var rewritten)) - obj[key] = rewritten; + if (key is "$id" or "$ref" && val is JsonValue jv && jv.TryGetValue(out var s) + && !string.IsNullOrEmpty(s)) + { + if (TryCanonicalizeUriString(s.Trim(), out var rewritten)) + obj[key] = rewritten; + } + else + RewriteGtsIdentifiersDeep(val); } - else - RewriteGtsIdentifiersDeep(val); - } - break; - } + break; + } case JsonArray arr: - { - foreach (var item in arr) - RewriteGtsIdentifiersDeep(item); - break; - } + { + foreach (var item in arr) + RewriteGtsIdentifiersDeep(item); + break; + } } } @@ -75,7 +75,7 @@ private static bool TryCanonicalizeUriString(string s, out JsonValue rewritten) return true; } - const string gtsUriScheme = "gts://"; + const string gtsUriScheme = GtsConstants.UriPrefix; if (s.StartsWith(gtsUriScheme, StringComparison.Ordinal)) { var inner = s[gtsUriScheme.Length..]; diff --git a/Gts.Store/Validation/IGtsJsonSchemaEngine.cs b/Gts.Store/Validation/IGtsJsonSchemaEngine.cs new file mode 100644 index 0000000..8644f78 --- /dev/null +++ b/Gts.Store/Validation/IGtsJsonSchemaEngine.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Nodes; +using Json.Schema; + +namespace Gts.Store.Validation; + +internal interface IGtsJsonSchemaEngine +{ + EvaluationResults Evaluate(JsonNode? instance, GtsId rootSchemaId, IReadOnlyDictionary schemas); + + EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDocument); + + IReadOnlyList FlattenErrors(EvaluationResults results); +} \ No newline at end of file diff --git a/Gts.Tests/Extraction/ExtractBasicTests.cs b/Gts.Tests/Extraction/ExtractBasicTests.cs index 428208e..f6f8172 100644 --- a/Gts.Tests/Extraction/ExtractBasicTests.cs +++ b/Gts.Tests/Extraction/ExtractBasicTests.cs @@ -18,13 +18,13 @@ public class ExtractBasicTests public void ExtractingOneOfDefaultIdsReturnsId(string id) { var gtsId = "gts.vendor.package.namespace.type.v0~a.b.c.d.v1"; - + var result = GtsJsonEntity.ExtractId(new JsonObject { [id] = gtsId, ["name"] = "Some Name" }); - + Assert.Equal(id, result.SelectedEntityField); Assert.Equal(gtsId, result.Id); } @@ -34,13 +34,13 @@ public void ExtractingOneOfDefaultIdsReturnsId(string id) public void ExtractingOneOfNonDefaultIdsReturnsNulls(string id) { var gtsId = "gts.vendor.package.namespace.type.v0~a.b.c.d.v1"; - + var result = GtsJsonEntity.ExtractId(new JsonObject { [id] = gtsId, ["name"] = "Some Name", }); - + Assert.Null(result.SelectedEntityField); Assert.Null(result.Id); } @@ -50,15 +50,15 @@ public void ExtractingOneOfNonDefaultIdsReturnsNulls(string id) public void ExtractingOneOfCustomIdsWithConfigReturnsId(string id) { var gtsId = "gts.vendor.package.namespace.type.v0~a.b.c.d.v1"; - + var result = GtsJsonEntity.ExtractId( new JsonObject { [id] = gtsId, ["name"] = "Some Name" }, - new (){ EntityIdPropertyNames = [id]}); - + new() { EntityIdPropertyNames = [id] }); + Assert.Equal(id, result.SelectedEntityField); Assert.Equal(gtsId, result.Id); } @@ -74,7 +74,7 @@ public void ExtractingOneOfDefaultIdsReturnsIdsByOrdering() ["gtsId"] = gtsId, // 1st ["$id"] = gtsId, // 2nd }); - + Assert.Equal(gtsId, result.Id); Assert.Equal("gtsId", result.SelectedEntityField); // 1st wins } @@ -89,7 +89,7 @@ public void ExtractingDollarIdReturnsIdWithStrippedPrefix() ["$schema"] = "http://json-schema.org/draft-07/schema#", ["type"] = "object", }); - + Assert.Equal("gts.vendor.package.namespace.type.v1.0~", result.Id); } @@ -103,7 +103,7 @@ public void ExtractingInvalidIdFallbacksToNextValidId() ["name"] = "Some Name", ["id"] = "gts.vendor.package.namespace.type.v1.0~", // valid }); - + Assert.Equal("gts.vendor.package.namespace.type.v1.0~", result.Id); Assert.Equal("id", result.SelectedEntityField); } @@ -116,7 +116,7 @@ public void ExtractingIdReturnsNullWhenValidIdDoesNotExist() { ["name"] = "Some Name", }); - + Assert.Null(result.Id); Assert.Null(result.SelectedEntityField); } diff --git a/Gts.Tests/Extraction/ExtractEntityTests.cs b/Gts.Tests/Extraction/ExtractEntityTests.cs index c848f80..9970fb5 100644 --- a/Gts.Tests/Extraction/ExtractEntityTests.cs +++ b/Gts.Tests/Extraction/ExtractEntityTests.cs @@ -13,11 +13,11 @@ public void ExtractingPopulatesRefsWithIds() ["$id"] = "gts.x.test.core.schema.v1~", ["$ref"] = "gts.x.test.core.base.v1~", }); - + Assert.Contains(entity.GtsRefs, r => r is { Id: "gts.x.test.core.schema.v1~", SourcePath: "$id" }); } - + [Fact] public void ExtractingPopulatesRefsWithExplicitRefs() { @@ -26,11 +26,11 @@ public void ExtractingPopulatesRefsWithExplicitRefs() ["$id"] = "gts.x.test.core.schema.v1~", ["$ref"] = "gts.x.test.core.base.v1~", }); - + Assert.Contains(entity.GtsRefs, r => r is { Id: "gts.x.test.core.base.v1~", SourcePath: "$ref" }); } - + [Fact] public void ExtractingPopulatesRefsWithExplicitNestedObjectRefs() { @@ -45,11 +45,11 @@ public void ExtractingPopulatesRefsWithExplicitNestedObjectRefs() }, }, }); - + Assert.Contains(entity.GtsRefs, r => r is { Id: "gts.x.test.core.field.v1~", SourcePath: "properties.field1.$ref" }); } - + [Fact] public void ExtractingPopulatesRefsWithExplicitNestedArrayRefs() { @@ -64,7 +64,7 @@ public void ExtractingPopulatesRefsWithExplicitNestedArrayRefs() } }, }); - + Assert.Contains(entity.GtsRefs, r => r is { Id: "gts.x.test.core.item.v1~", SourcePath: "items[0].$ref" }); } diff --git a/Gts.Tests/Extraction/ExtractSchemaTests.cs b/Gts.Tests/Extraction/ExtractSchemaTests.cs index 8dade53..8918b16 100644 --- a/Gts.Tests/Extraction/ExtractSchemaTests.cs +++ b/Gts.Tests/Extraction/ExtractSchemaTests.cs @@ -9,13 +9,13 @@ public class ExtractSchemaTests public void ExtractingIdReturnsSchemaFromIdByDefault() { var gtsId = "gts.vendor.package.namespace.type.v0~a.b.c.d.v1"; - + var result = GtsJsonEntity.ExtractId(new JsonObject { ["id"] = gtsId, ["name"] = "Some Name" }); - + Assert.Equal(gtsId, result.Id); Assert.Equal("gts.vendor.package.namespace.type.v0~", result.SchemaId); Assert.Equal("id", result.SelectedSchemaIdField); @@ -34,14 +34,14 @@ public void ExtractingIdReturnsSchemaFromSchemaField(string field) { var gtsId = "a.b.c.d.v1"; var schema = "gts.vendor.package.namespace.type.v0~"; - + var result = GtsJsonEntity.ExtractId(new JsonObject { ["id"] = gtsId, ["name"] = "Some Name", [field] = schema, }); - + Assert.Equal(gtsId, result.Id); Assert.Equal("gts.vendor.package.namespace.type.v0~", result.SchemaId); Assert.Equal(field, result.SelectedSchemaIdField); @@ -57,7 +57,7 @@ public void ExtractingDollarSchemaReturnsSchemaFromSpecifiedField() ["$schema"] = "http://json-schema.org/draft-07/schema#", ["type"] = "object", }); - + Assert.Equal("gts.vendor.package.namespace.type.v1.0~", result.Id); Assert.True(result.IsSchema); Assert.Equal("$schema", result.SelectedSchemaIdField); diff --git a/Gts.Tests/Gts.Tests.csproj b/Gts.Tests/Gts.Tests.csproj index 3a0b7e0..ddca6f7 100644 --- a/Gts.Tests/Gts.Tests.csproj +++ b/Gts.Tests/Gts.Tests.csproj @@ -1,10 +1,6 @@ - net8.0 - enable - enable - false true diff --git a/Gts.Tests/GtsIdSegmentTests.cs b/Gts.Tests/GtsIdSegmentTests.cs index 50f0858..f2bb077 100644 --- a/Gts.Tests/GtsIdSegmentTests.cs +++ b/Gts.Tests/GtsIdSegmentTests.cs @@ -21,7 +21,7 @@ public void ToStringForVendorAndPackageReturnsPattern() Vendor = "vendor", Package = "pkg" }; - + Assert.Equal("vendor.pkg.*", segment.ToString()); } @@ -34,7 +34,7 @@ public void ToStringForVendorAndPackageAndNamespaceReturnsPattern() Package = "pkg", Namespace = "ns" }; - + Assert.Equal("vendor.pkg.ns.*", segment.ToString()); } @@ -48,7 +48,7 @@ public void ToStringForVendorAndPackageAndNamespaceAndTypeReturnsPattern() Namespace = "ns", Type = "type" }; - + Assert.Equal("vendor.pkg.ns.type.*", segment.ToString()); } @@ -63,7 +63,7 @@ public void ToStringForVendorAndPackageAndNamespaceAndTypeAndMajorReturnsId() Type = "type", VersionMajor = 1, }; - + Assert.Equal("vendor.pkg.ns.type.v1", segment.ToString()); } @@ -79,7 +79,7 @@ public void ToStringForVendorAndPackageAndNamespaceAndTypeAndMajorAndMinorReturn VersionMajor = 1, VersionMinor = 2, }; - + Assert.Equal("vendor.pkg.ns.type.v1.2", segment.ToString()); } } diff --git a/Gts.Tests/GtsIdTests.cs b/Gts.Tests/GtsIdTests.cs index 6227a4d..3e9bf8e 100644 --- a/Gts.Tests/GtsIdTests.cs +++ b/Gts.Tests/GtsIdTests.cs @@ -18,7 +18,7 @@ public void CanBeParsedAsSingleSegmentType() public void CanBeParsedAsMultipleSegmentsType() { var id = GtsId.Parse("gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.v1.0~"); - + Assert.True(id.IsType); Assert.Equal(2, id.Segments.Count); } @@ -31,7 +31,7 @@ public void CanBeParsedAsMultipleSegmentsInstance() Assert.True(id.IsInstance); Assert.Equal(2, id.Segments.Count); } - + [Fact] public void CanBeParsedAsSingleSegmentPattern() { @@ -47,33 +47,33 @@ public void CanBeParsedAsSingleSegmentPattern() public void CanBeParsedAsMultipleSegmentsPattern() { var id = GtsId.ParsePattern("gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.*"); - + Assert.False(id.IsType); Assert.False(id.IsInstance); Assert.True(id.IsPattern); Assert.Equal(2, id.Segments.Count); } - + [Fact] public void CannotBeParsedWithTrailingStringCharacters() { var str = "gts.vendor.package.namespace.type.v1.0~ "; - + Assert.False(GtsId.TryParse(str, out _)); Assert.Throws(() => GtsId.Parse(str)); - + Assert.False(GtsId.TryParsePattern(str, out _)); Assert.Throws(() => GtsId.ParsePattern(str)); } - + [Fact] public void CannotBeParsedWithNullAsInput() { string? str = null; - + Assert.False(GtsId.TryParse(str, out _)); Assert.Throws(() => GtsId.Parse(str)); - + Assert.False(GtsId.TryParsePattern(str, out _)); Assert.Throws(() => GtsId.ParsePattern(str)); } @@ -83,7 +83,7 @@ public void ToGuidProducesGuidInGtsNamespace() { var id = GtsId.Parse("gts.vendor.package.namespace.type.v1.0~"); var guid = id.ToGuid(); - + Assert.Equal(GuidUtils.Create(GuidUtils.GtsNamespace, id.Id), guid); } @@ -110,7 +110,7 @@ public void GetHashCodeIsTheSameAsGetHashCodeForString() { var str = "gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.v1.0"; var id = GtsId.Parse(str); - + Assert.Equal( StringComparer.Ordinal.GetHashCode(str), id.GetHashCode()); @@ -121,11 +121,11 @@ public void EqualsIsTheSameAsEqualsForString() { var str1 = "gts.vendor.package.namespace.type.v1.0~vendor1.package1.namespace1.type1.v1.0"; var str2 = "gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.v1.0"; - + var id1 = GtsId.Parse(str1); var id12 = GtsId.Parse(str1); var id2 = GtsId.Parse(str2); - + Assert.Equal(id1, id12); Assert.NotEqual(id1, id2); } diff --git a/Gts.Tests/Matching/PatternMatchingTests.cs b/Gts.Tests/Matching/PatternMatchingTests.cs index 388deeb..f8ce9d0 100644 --- a/Gts.Tests/Matching/PatternMatchingTests.cs +++ b/Gts.Tests/Matching/PatternMatchingTests.cs @@ -37,7 +37,7 @@ public void DoesNotMatchShorterNonWildcardPattern() { var candidate = GtsId.Parse("gts.x.pkg.ns.type.v1.5~abc.app.events.custom.v1.2"); var pattern = GtsId.ParsePattern("gts.x.pkg.ns.type.v1.5"); - + Assert.False(candidate.Matches(pattern)); Assert.False(candidate.Matches("gts.x.pkg.ns.type.v1.5")); } diff --git a/Gts.Tests/Parsing/IdentifierParserTests.cs b/Gts.Tests/Parsing/IdentifierParserTests.cs index 9e75ca5..90c1424 100644 --- a/Gts.Tests/Parsing/IdentifierParserTests.cs +++ b/Gts.Tests/Parsing/IdentifierParserTests.cs @@ -1,6 +1,7 @@ using Gts.Parsing; namespace Gts.Tests.Parsing; + using Pidgin; public class IdentifierParserTests diff --git a/Gts.Tests/Parsing/InstanceParserTests.cs b/Gts.Tests/Parsing/InstanceParserTests.cs index d92ceea..99374f8 100644 --- a/Gts.Tests/Parsing/InstanceParserTests.cs +++ b/Gts.Tests/Parsing/InstanceParserTests.cs @@ -17,7 +17,7 @@ public void InstanceParsesDoubleSegment() var segments = id.ToArray(); Assert.Equal(2, segments.Length); } - + [Fact] public void InstanceParsesTripleSegment() { diff --git a/Gts.Tests/Parsing/PatternParserTests.cs b/Gts.Tests/Parsing/PatternParserTests.cs index cb588c3..e1c79f4 100644 --- a/Gts.Tests/Parsing/PatternParserTests.cs +++ b/Gts.Tests/Parsing/PatternParserTests.cs @@ -9,19 +9,19 @@ public class PatternParserTests public void PatternParsesWildcard() { var segment = Parsers.Pattern.ParseOrThrow("*"); - + Assert.Null(segment.Vendor); Assert.Null(segment.Package); Assert.Null(segment.Namespace); Assert.Null(segment.Type); Assert.Null(segment.Version); } - + [Fact] public void PatternParsesVendor() { var segment = Parsers.Pattern.ParseOrThrow("vendor.*"); - + Assert.Equal("vendor", segment.Vendor); Assert.Null(segment.Package); Assert.Null(segment.Namespace); @@ -33,7 +33,7 @@ public void PatternParsesVendor() public void PatternParsesPackage() { var segment = Parsers.Pattern.ParseOrThrow("vendor.package.*"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Null(segment.Namespace); @@ -45,7 +45,7 @@ public void PatternParsesPackage() public void PatternParsesNamespace() { var segment = Parsers.Pattern.ParseOrThrow("vendor.package.namespace.*"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Equal("namespace", segment.Namespace); @@ -57,7 +57,7 @@ public void PatternParsesNamespace() public void PatternParsesType() { var segment = Parsers.Pattern.ParseOrThrow("vendor.package.namespace.type.*"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Equal("namespace", segment.Namespace); @@ -69,7 +69,7 @@ public void PatternParsesType() public void PatternParsesVersion() { var segment = Parsers.Pattern.ParseOrThrow("vendor.package.namespace.type.v1.0"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Equal("namespace", segment.Namespace); @@ -85,7 +85,7 @@ public void PatternParsesVersion() public void PatternParsesVersionAndTilde() { var segment = Parsers.Pattern.ParseOrThrow("vendor.package.namespace.type.v1.0~"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Equal("namespace", segment.Namespace); @@ -101,11 +101,11 @@ public void PatternParsesVersionAndTilde() public void GtsPatternParsesSingleSegment() { var id = Parsers.GtsPattern.ParseOrThrow("gts.vendor.package.namespace.type.*"); - + Assert.False(id.IsType); Assert.False(id.IsInstance); Assert.True(id.IsPattern); - + var segments = id.ToArray(); Assert.Single(segments); } @@ -114,7 +114,7 @@ public void GtsPatternParsesSingleSegment() public void GtsPatternParsesMultipleSegments() { var id = Parsers.GtsPattern.ParseOrThrow("gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.*"); - + Assert.False(id.IsType); Assert.False(id.IsInstance); Assert.True(id.IsPattern); @@ -127,7 +127,7 @@ public void GtsPatternParsesMultipleSegments() public void GtsPatternParsesMultipleWithWildcardAtTheEnd() { var id = Parsers.GtsPattern.ParseOrThrow("gts.vendor.package.namespace.type.v1.0~*"); - + Assert.False(id.IsType); Assert.False(id.IsInstance); Assert.True(id.IsPattern); @@ -140,7 +140,7 @@ public void GtsPatternParsesMultipleWithWildcardAtTheEnd() public void GtsPatternParsesMultipleWithTildeAtTheEnd() { var id = Parsers.GtsPattern.ParseOrThrow("gts.vendor.package.namespace.type.v1.0~"); - + Assert.False(id.IsType); Assert.False(id.IsInstance); Assert.True(id.IsPattern); diff --git a/Gts.Tests/Parsing/SegmentParserTests.cs b/Gts.Tests/Parsing/SegmentParserTests.cs index d4c4adb..4bc953f 100644 --- a/Gts.Tests/Parsing/SegmentParserTests.cs +++ b/Gts.Tests/Parsing/SegmentParserTests.cs @@ -9,26 +9,26 @@ public class SegmentParserTests public void SegmentParsesSegmentWithMajorVersion() { var segment = Parsers.Segment.ParseOrThrow("vendor.package.namespace.type.v1"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Equal("namespace", segment.Namespace); Assert.Equal("type", segment.Type); - + Assert.Equal(1, segment.Version.Value.Major); Assert.Null(segment.Version.Value.Minor); } - + [Fact] public void SegmentParsesSegmentWithMajorAndMinorVersion() { var segment = Parsers.Segment.ParseOrThrow("vendor.package.namespace.type.v1.0"); - + Assert.Equal("vendor", segment.Vendor); Assert.Equal("package", segment.Package); Assert.Equal("namespace", segment.Namespace); Assert.Equal("type", segment.Type); - + Assert.Equal(1, segment.Version.Value.Major); Assert.Equal(0, segment.Version.Value.Minor); } diff --git a/Gts.Tests/Parsing/TypeParserTests.cs b/Gts.Tests/Parsing/TypeParserTests.cs index f355e7b..254527d 100644 --- a/Gts.Tests/Parsing/TypeParserTests.cs +++ b/Gts.Tests/Parsing/TypeParserTests.cs @@ -13,7 +13,7 @@ public void TypeParsesSingleSegment() Assert.True(id.IsType); Assert.False(id.IsInstance); - + var segments = id.ToArray(); Assert.Single(segments); } diff --git a/Gts.Tests/Parsing/VersionParserTests.cs b/Gts.Tests/Parsing/VersionParserTests.cs index b5b504f..e040ef8 100644 --- a/Gts.Tests/Parsing/VersionParserTests.cs +++ b/Gts.Tests/Parsing/VersionParserTests.cs @@ -17,7 +17,7 @@ public void VersionMajorDoesNotParseNumberWithoutVPrefix() { Assert.Throws>(() => Parsers.VersionMajor.ParseOrThrow("123")); } - + [Fact] public void VersionMinorParsesNumberWithoutVPrefix() { diff --git a/Gts.Tests/StagingConcurrencyTests.cs b/Gts.Tests/StagingConcurrencyTests.cs new file mode 100644 index 0000000..57b8410 --- /dev/null +++ b/Gts.Tests/StagingConcurrencyTests.cs @@ -0,0 +1,216 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests; + +/// +/// Concurrency test for the staging overlay a validate=true batch relies on: an +/// entity that is staged but not committed (e.g. a batch entry that fails +/// validation and is discarded) must never be observable through the committed +/// read path. Several probe tasks hammer the committed read for a +/// deliberately-invalid id while a writer stages the whole batch and then +/// commits the valid entries but discards the invalid one - mirroring the batch +/// handler's outcome. Repeated over many cycles. +/// serializes map access with a lock, so the probes run genuinely concurrently +/// with the writer. +/// +public class StagingConcurrencyTests +{ + private const string Draft7 = "http://json-schema.org/draft-07/schema#"; + + private static GtsJsonEntity Schema(string typeId) + { + var json = $$"""{"$$id":"gts://{{typeId}}","$$schema":"{{Draft7}}","type":"object"}"""; + return GtsJsonEntity.ExtractEntity(JsonNode.Parse(json)!.AsObject()); + } + + private static GtsJsonEntity Schema(string typeId, string title) + { + var json = $$"""{"$$id":"gts://{{typeId}}","$$schema":"{{Draft7}}","type":"object","title":"{{title}}"}"""; + return GtsJsonEntity.ExtractEntity(JsonNode.Parse(json)!.AsObject()); + } + + // Two stages that resolve to the same id must each get their own token, and a commit must never + // silently overwrite different committed content - it reports a conflict instead. This covers both a + // duplicated id inside one validate=true batch and two concurrent batches racing on the same id. + [Fact] + public async Task Staging_the_same_id_twice_yields_distinct_tokens_and_commit_does_not_overwrite() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var id = "gts.x.dotnetdup._.t.v1~"; + + var tokenA = await registry.StageAsync(Schema(id, "a")); + var tokenB = await registry.StageAsync(Schema(id, "b")); + Assert.NotEqual(tokenA, tokenB); + + // First commit publishes A. + Assert.Equal(GtsSaveOutcome.Added, await registry.CommitStagedAsync(tokenA)); + // Committing B (different content for the same id) must conflict, not clobber A. + Assert.Equal(GtsSaveOutcome.Conflict, await registry.CommitStagedAsync(tokenB)); + + var committed = await registry.GetByInstanceIdAsync(id); + Assert.NotNull(committed); + Assert.Equal("a", committed!.Content["title"]?.GetValue()); + } + + // Committing the same content twice (e.g. an idempotent duplicate) is Unchanged, never a conflict. + [Fact] + public async Task Committing_identical_staged_content_twice_is_unchanged() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var id = "gts.x.dotnetdup._.same.v1~"; + + var tokenA = await registry.StageAsync(Schema(id, "x")); + var tokenB = await registry.StageAsync(Schema(id, "x")); + Assert.Equal(GtsSaveOutcome.Added, await registry.CommitStagedAsync(tokenA)); + Assert.Equal(GtsSaveOutcome.Unchanged, await registry.CommitStagedAsync(tokenB)); + } + + // Discarding one staged entry must not affect another entry that happens to share the same id. + [Fact] + public async Task Discarding_one_token_leaves_another_staged_entry_for_the_same_id() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var id = "gts.x.dotnetdup._.iso.v1~"; + + var tokenA = await registry.StageAsync(Schema(id, "keep")); + var tokenB = await registry.StageAsync(Schema(id, "drop")); + await registry.DiscardStagedAsync(tokenB); + + // A survives and can still be committed. + Assert.Equal(GtsSaveOutcome.Added, await registry.CommitStagedAsync(tokenA)); + var committed = await registry.GetByInstanceIdAsync(id); + Assert.Equal("keep", committed!.Content["title"]?.GetValue()); + } + + // Session isolation (regression for the cross-request staging leak): a snapshot taken for one staging + // session must overlay only that session's staged entries, never another session's unvalidated ones, and + // a null session must be committed-only. Without this, one batch could resolve a $ref/parent against + // another batch's not-yet-validated entry and commit a dangling reference. + [Fact] + public async Task Snapshot_only_overlays_its_own_staging_session() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var idA = "gts.x.dotnetsess._.a.v1~"; + var idB = "gts.x.dotnetsess._.b.v1~"; + const string sessionA = "session-a"; + const string sessionB = "session-b"; + + await registry.StageAsync(Schema(idA, "a"), sessionA); + await registry.StageAsync(Schema(idB, "b"), sessionB); + + static bool Has(IReadOnlyList snap, string id) => + snap.Any(e => string.Equals(GtsJsonEntity.ExtractId(e.Content).Id, id, StringComparison.Ordinal)); + + var snapA = await registry.SnapshotForReadAsync(sessionA); + Assert.True(Has(snapA, idA)); + Assert.False(Has(snapA, idB)); // B's staged entry must not leak into A's validation + + var snapB = await registry.SnapshotForReadAsync(sessionB); + Assert.True(Has(snapB, idB)); + Assert.False(Has(snapB, idA)); + + var committedOnly = await registry.SnapshotForReadAsync(); + Assert.False(Has(committedOnly, idA)); + Assert.False(Has(committedOnly, idB)); + } + + // Atomic batch publication (regression for the non-atomic commit loop): if any target in the survivor set + // conflicts with committed content, the whole set is published as nothing - a dependent is never committed + // against a target whose content changed after validation. + [Fact] + public async Task CommitStagedBatch_publishes_nothing_when_any_target_conflicts() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var conflictId = "gts.x.dotnetatomic._.parent.v1~"; + var dependentId = "gts.x.dotnetatomic._.child.v1~"; + const string session = "batch-1"; + + // A concurrent writer already committed the parent id with different content. + Assert.Equal(GtsSaveOutcome.Added, await registry.TrySaveAsync(Schema(conflictId, "committed"))); + + var conflictToken = await registry.StageAsync(Schema(conflictId, "staged"), session); + var dependentToken = await registry.StageAsync(Schema(dependentId, "ok"), session); + + var outcomes = await registry.CommitStagedBatchAsync(new[] { dependentToken, conflictToken }); + Assert.Equal(GtsSaveOutcome.Conflict, outcomes[0]); + Assert.Equal(GtsSaveOutcome.Conflict, outcomes[1]); + + // Nothing from the batch was published: the dependent is absent and the parent keeps its committed content. + Assert.Null(await registry.GetByInstanceIdAsync(dependentId)); + var parent = await registry.GetByInstanceIdAsync(conflictId); + Assert.Equal("committed", parent!.Content["title"]?.GetValue()); + } + + // The atomic batch commit publishes the entire dependency set when no target conflicts. + [Fact] + public async Task CommitStagedBatch_publishes_all_when_no_conflict() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var idA = "gts.x.dotnetatomic2._.a.v1~"; + var idB = "gts.x.dotnetatomic2._.b.v1~"; + const string session = "batch-2"; + + var tokenA = await registry.StageAsync(Schema(idA, "a"), session); + var tokenB = await registry.StageAsync(Schema(idB, "b"), session); + + var outcomes = await registry.CommitStagedBatchAsync(new[] { tokenA, tokenB }); + Assert.Equal(GtsSaveOutcome.Added, outcomes[0]); + Assert.Equal(GtsSaveOutcome.Added, outcomes[1]); + + Assert.NotNull(await registry.GetByInstanceIdAsync(idA)); + Assert.NotNull(await registry.GetByInstanceIdAsync(idB)); + } + + [Fact] + public async Task Staged_entity_is_never_visible_to_concurrent_committed_reads() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + const int cycles = 10; + const int probes = 4; + const int validPerBatch = 60; + long leaks = 0; + + for (var cycle = 0; cycle < cycles; cycle++) + { + var ns = $"gts.x.dotnetconc{cycle}._"; + var invalidId = $"{ns}.invalid.v1~"; + var validIds = Enumerable.Range(0, validPerBatch).Select(i => $"{ns}.t{i}.v1~").ToList(); + + using var cts = new CancellationTokenSource(); + var probers = Enumerable.Range(0, probes).Select(_ => Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + // The invalid entry must NEVER be observable through the committed read path. + if (await registry.GetByInstanceIdAsync(invalidId) is not null) + Interlocked.Increment(ref leaks); + } + })).ToArray(); + + // Two-phase, mirroring the batch handler: stage every entry (invisible to + // public reads), then commit the valid ids and discard the invalid one. + var staged = new List<(string Key, bool Valid)>(); + foreach (var id in validIds) + staged.Add((await registry.StageAsync(Schema(id)), true)); + staged.Add((await registry.StageAsync(Schema(invalidId)), false)); + + foreach (var (key, valid) in staged) + { + if (valid) + await registry.CommitStagedAsync(key); + else + await registry.DiscardStagedAsync(key); + } + + cts.Cancel(); + await Task.WhenAll(probers); + + Assert.Null(await registry.GetByInstanceIdAsync(invalidId)); + Assert.NotNull(await registry.GetByInstanceIdAsync(validIds[0])); + } + + Assert.Equal(0, Interlocked.Read(ref leaks)); + } +} diff --git a/Gts.Tests/Validation/InstanceCastTests.cs b/Gts.Tests/Validation/InstanceCastTests.cs index c7a03a7..11c2c2e 100644 --- a/Gts.Tests/Validation/InstanceCastTests.cs +++ b/Gts.Tests/Validation/InstanceCastTests.cs @@ -10,6 +10,8 @@ public class InstanceCastTests [Fact] public async Task CastInstanceAsync_upgrade_minor_when_evolution_allows() { + // Closed models (additionalProperties:false): adding an optional property in v1.1 is a + // backward-compatible evolution (old data has no extra keys, so it still validates under v1.1). const string s0 = """ { "$$id": "gts://gts.x.cast.demo.item.v1.0~", @@ -17,8 +19,10 @@ public async Task CastInstanceAsync_upgrade_minor_when_evolution_allows() "type": "object", "required": ["a"], "properties": { + "id": { "type": "string" }, "a": { "type": "string" } - } + }, + "additionalProperties": false } """; @@ -29,15 +33,17 @@ public async Task CastInstanceAsync_upgrade_minor_when_evolution_allows() "type": "object", "required": ["a"], "properties": { + "id": { "type": "string" }, "a": { "type": "string" }, "b": { "type": "string", "default": "default-b" } - } + }, + "additionalProperties": false } """; const string instance = """ { - "gtsId": "gts.x.cast.demo.item.v1.0~x.cast.ns.myinst.v1.0", + "id": "gts.x.cast.demo.item.v1.0~x.cast.ns.myinst.v1.0", "a": "hello" } """; @@ -94,7 +100,7 @@ public async Task CastInstanceAsync_fails_when_backward_evolution_blocks_upgrade GtsId.Parse("gts.x.cast.demo.doc.v1.1~")); Assert.False(result.Ok); - Assert.Equal("IncompatibleMinorEvolution", result.FailureReason); + Assert.Equal(GtsValidationFailure.CastValidationFailed, result.FailureReason); Assert.NotNull(result.Comparison); Assert.False(result.Comparison!.IsBackwardEvolutionCompatible); } @@ -139,6 +145,6 @@ public async Task CastInstanceAsync_fails_when_target_is_not_minor_variant() GtsId.Parse("gts.x.cast.other.thing.v1.0~")); Assert.False(result.Ok); - Assert.Equal("NotMinorVariantPair", result.FailureReason); + Assert.Equal(GtsValidationFailure.NotMinorVariantPair, result.FailureReason); } } diff --git a/Gts.Tests/Validation/InstanceValidationTests.cs b/Gts.Tests/Validation/InstanceValidationTests.cs index fa861d7..ae5da24 100644 --- a/Gts.Tests/Validation/InstanceValidationTests.cs +++ b/Gts.Tests/Validation/InstanceValidationTests.cs @@ -155,7 +155,7 @@ public async Task Invalid_instance_fails_schema_validation() "gts.x.test6.events.type.v1~x.test6.invalid.event.v1.0~x.y._.some_event2.v1.0"); Assert.False(result.Ok); - Assert.Equal("SchemaValidationFailed", result.FailureReason); + Assert.Equal(GtsValidationFailure.SchemaValidationFailed, result.FailureReason); Assert.NotNull(result.SchemaErrors); Assert.NotEmpty(result.SchemaErrors!); } @@ -166,7 +166,7 @@ public async Task Missing_instance_returns_not_found() var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); var result = await registry.ValidateInstanceAsync("gts.x.nonexistent.pkg.ns.type.v1.0"); Assert.False(result.Ok); - Assert.Equal("InstanceNotFound", result.FailureReason); + Assert.Equal(GtsValidationFailure.InstanceNotFound, result.FailureReason); } [Fact] @@ -302,6 +302,65 @@ public async Task Anonymous_invalid_instance_fails() var result = await registry.ValidateInstanceAsync("8b2e3f45-6789-4abc-8123-bcdef1234567"); Assert.False(result.Ok); - Assert.Equal("SchemaValidationFailed", result.FailureReason); + Assert.Equal(GtsValidationFailure.SchemaValidationFailed, result.FailureReason); + } + + [Fact] + public async Task Catastrophic_pattern_is_bounded_and_does_not_hang() + { + // An untrusted schema `pattern` that is a classic catastrophic- + // backtracking regex, matched against an adversarial instance value, + // must not hang: JsonSchema.Net bounds regex match time, and the + // timeout is surfaced as a validation failure ("regular expression + // match timed out") rather than propagating - the same class of + // protection gts-go and gts-python get from a match timeout. + const string baseSchema = """ + { + "$$id": "gts://gts.x.redos.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "type", "code"], + "properties": { + "type": { "type": "string" }, + "id": { "type": "string" }, + "code": { "type": "string", "pattern": "(a+)+$" } + }, + "additionalProperties": true + } + """; + + const string derivedSchema = """ + { + "$$id": "gts://gts.x.redos.events.type.v1~x.redos.evt.item.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { "$$ref": "gts://gts.x.redos.events.type.v1~" }, + { "type": "object", "properties": { "type": { "const": "gts.x.redos.events.type.v1~x.redos.evt.item.v1.0~" } } } + ] + } + """; + + var adversarial = new string('a', 40_000) + "!"; + var instance = $$""" + { + "type": "gts.x.redos.events.type.v1~x.redos.evt.item.v1.0~", + "id": "gts.x.redos.events.type.v1~x.redos.evt.item.v1.0~x.y._.evt.v1.0", + "code": "{{adversarial}}" + } + """; + + var registry = await RegistryWithSchemasAndInstanceAsync(baseSchema, derivedSchema, instance); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await registry.ValidateInstanceAsync( + "gts.x.redos.events.type.v1~x.redos.evt.item.v1.0~x.y._.evt.v1.0"); + stopwatch.Stop(); + + // The point is that validation *returns* (bounded), rather than pinning + // a CPU forever. The adversarial value fails the pattern, so Ok is false. + Assert.False(result.Ok); + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(60), + $"validation took {stopwatch.Elapsed}, expected a bounded match time"); } } diff --git a/Gts.Tests/Validation/JsonInfrastructureTests.cs b/Gts.Tests/Validation/JsonInfrastructureTests.cs new file mode 100644 index 0000000..fa480b8 --- /dev/null +++ b/Gts.Tests/Validation/JsonInfrastructureTests.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Nodes; +using Gts.Store.Validation; + +namespace Gts.Tests.Validation; + +public class JsonInfrastructureTests +{ + [Fact] + public void Json_pointer_resolves_escaped_tokens_and_array_indices() + { + var root = JsonNode.Parse("""{"a/b":{"~key":["zero","one"]}}"""); + + var found = GtsJsonPointer.TryEvaluate(root, "#/a~1b/~0key/1", out var value); + + Assert.True(found); + Assert.Equal("one", value!.GetValue()); + } + + [Fact] + public void Schema_engine_uses_strict_registered_uuid_format() + { + var schema = JsonNode.Parse(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string", + "format": "uuid" + } + """)!.AsObject(); + + var valid = GtsJsonSchemaEngine.Default.EvaluateInline(JsonValue.Create("11111111-2222-3333-8444-555555555555"), schema); + var invalid = GtsJsonSchemaEngine.Default.EvaluateInline(JsonValue.Create("{11111111-2222-3333-8444-555555555555}"), schema); + + Assert.True(valid.IsValid); + Assert.False(invalid.IsValid); + } + + [Theory] + [InlineData("gts.x.pkg.ns.type.v1~")] + [InlineData("gts.x.pkg.ns.type.v1~123e4567-e89b-42d3-a456-426614174000")] + public void Shared_id_parser_accepts_supported_identifier_shapes(string id) + { + Assert.True(GtsId.TryParse(id, out _)); + } + + [Theory] + [InlineData("gts.x.pkg.ns.type.v-1~")] + [InlineData("gts.x.pkg.ns.type.v1.-1~")] + public void Shared_id_parser_rejects_negative_versions(string id) + { + Assert.False(GtsId.TryParse(id, out _)); + } +} \ No newline at end of file diff --git a/Gts.Tests/Validation/SchemaValidationTests.cs b/Gts.Tests/Validation/SchemaValidationTests.cs index 38fe937..46ffcd1 100644 --- a/Gts.Tests/Validation/SchemaValidationTests.cs +++ b/Gts.Tests/Validation/SchemaValidationTests.cs @@ -97,7 +97,7 @@ public async Task Derived_fails_when_precedent_missing() var r = await registry.ValidateSchemaAsync(id, derived); Assert.False(r.Ok); - Assert.Equal("PrecedentIncompatible", r.FailureReason); + Assert.Equal(GtsValidationFailure.PrecedentIncompatible, r.FailureReason); Assert.Contains(r.Errors!, e => e.Contains("Precedent schema", StringComparison.Ordinal)); } @@ -120,7 +120,7 @@ public async Task Invalid_ref_format_fails() var r = await registry.ValidateSchemaAsync("gts.x.schema12.badref.type.v1~"); Assert.False(r.Ok); - Assert.Equal("InvalidRefFormat", r.FailureReason); + Assert.Equal(GtsValidationFailure.InvalidRefFormat, r.FailureReason); Assert.NotNull(r.Errors); Assert.NotEmpty(r.Errors); } @@ -132,7 +132,7 @@ public async Task Non_type_id_returns_invalid() var r = await registry.ValidateSchemaAsync("gts.x.schema12.events.type.v1.0"); Assert.False(r.Ok); - Assert.Equal("InvalidSchemaId", r.FailureReason); + Assert.Equal(GtsValidationFailure.InvalidSchemaId, r.FailureReason); } [Fact] @@ -142,7 +142,7 @@ public async Task Missing_entity_returns_schema_not_found() var r = await registry.ValidateSchemaAsync("gts.x.schema12.events.type.v1~"); Assert.False(r.Ok); - Assert.Equal("SchemaNotFound", r.FailureReason); + Assert.Equal(GtsValidationFailure.SchemaNotFound, r.FailureReason); } [Fact] @@ -169,7 +169,40 @@ public async Task Instance_id_returns_invalid_schema_id() var r = await registry.ValidateSchemaAsync("gts.x.schema12.inst.type.v1~x.y._.i1.v1.0"); Assert.False(r.Ok); - Assert.Equal("InvalidSchemaId", r.FailureReason); + Assert.Equal(GtsValidationFailure.InvalidSchemaId, r.FailureReason); + } + + [Fact] + public async Task Descendant_of_ancestor_with_cross_dialect_ref_is_rejected() + { + // Issue C: the cross-dialect $ref lives on an ancestor; the descendant + // derives by re-declaration and references neither the ancestor nor the + // 2020-12 target. Validating the descendant (and an instance of it) must + // still be rejected because its ancestor is invalid (spec §11.0 + §12). + const string foreign = """ + {"$$id":"gts://gts.x.dialanc.ns.foreign.v1~","$$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"note":{"type":"string"}}} + """; + const string ancestor = """ + {"$$id":"gts://gts.x.dialanc.ns.base.v1~","$$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"ext":{"$$ref":"gts://gts.x.dialanc.ns.foreign.v1~"}}} + """; + const string child = """ + {"$$id":"gts://gts.x.dialanc.ns.base.v1~x.dialanc._.child.v1~","$$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"label":{"type":"string"}}} + """; + const string instance = """ + {"type":"gts.x.dialanc.ns.base.v1~x.dialanc._.child.v1~","id":"gts.x.dialanc.ns.base.v1~x.dialanc._.child.v1~x.dialanc._.thing.v1.0","label":"ok"} + """; + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(foreign)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(ancestor)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(child)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var schemaResult = await registry.ValidateSchemaAsync("gts.x.dialanc.ns.base.v1~x.dialanc._.child.v1~"); + Assert.False(schemaResult.Ok); + + var instanceResult = await registry.ValidateInstanceAsync( + "gts.x.dialanc.ns.base.v1~x.dialanc._.child.v1~x.dialanc._.thing.v1.0"); + Assert.False(instanceResult.Ok); } [Fact] @@ -187,6 +220,6 @@ public async Task Stored_non_schema_under_type_id_returns_not_a_schema() var r = await registry.ValidateSchemaAsync("gts.x.schema12.misc.type.v1~"); Assert.False(r.Ok); - Assert.Equal("NotASchema", r.FailureReason); + Assert.Equal(GtsValidationFailure.NotASchema, r.FailureReason); } } diff --git a/Gts/Extraction/GtsJsonEntity.cs b/Gts/Extraction/GtsJsonEntity.cs index b5ee08b..b4bc8b9 100644 --- a/Gts/Extraction/GtsJsonEntity.cs +++ b/Gts/Extraction/GtsJsonEntity.cs @@ -8,7 +8,7 @@ namespace Gts.Extraction; /// public sealed class GtsJsonEntity { - private const string GtsUriPrefix = "gts://"; + private const string GtsUriPrefix = GtsConstants.UriPrefix; /// Parsed GTS ID if the entity has a valid GTS identifier; null for anonymous instances. public GtsId? GtsId { get; } @@ -153,7 +153,7 @@ public static IReadOnlyList ExtractReferences(JsonNode? node) { var refs = new List(); var seen = new HashSet(StringComparer.Ordinal); - WalkAndCollectRefs(node, "", refs, seen); + WalkAndCollectRefs(node, "", refs, seen, 0); return refs; } @@ -266,11 +266,11 @@ private static bool IsJsonSchema(JsonObject json, GtsExtractOptions options) if (!json.TryGetPropertyValue(propertyName, out var node)) return null; - var s = node?.GetValue(); - if (string.IsNullOrWhiteSpace(s)) + if (node is not JsonValue value || value.GetValueKind() != JsonValueKind.String || + !value.TryGetValue(out var s) || string.IsNullOrWhiteSpace(s)) return null; - var trimmed = s!.Trim(); + var trimmed = s.Trim(); if (trimmed.Length == 0) return null; @@ -282,35 +282,13 @@ private static bool IsJsonSchema(JsonObject json, GtsExtractOptions options) private static (string? field, string? value) FirstNonEmptyField(JsonObject json, IReadOnlyList propertyNames) { - // TODO: check this impl - // foreach (var (key, _) in json) - // { - // if (propertyNames.Contains(key)) - // { - // var val = GetFieldValue(json, key); - // if (!string.IsNullOrEmpty(val) && IsValidGtsId(val)) - // return (key, val); - // } - // } - // - // foreach (var (key, _) in json) - // { - // if (propertyNames.Contains(key)) - // { - // var val = GetFieldValue(json, key); - // if (!string.IsNullOrEmpty(val)) - // return (key, val); - // } - // } - - // TODO: bellow original AI impl foreach (var name in propertyNames) { var val = GetFieldValue(json, name); if (!string.IsNullOrEmpty(val) && IsValidGtsId(val)) return (name, val); } - + foreach (var name in propertyNames) { var val = GetFieldValue(json, name); @@ -326,9 +304,9 @@ private static bool IsValidGtsId(string s) return GtsId.TryParse(s, out _) || GtsId.TryParsePattern(s, out _); } - private static void WalkAndCollectRefs(JsonNode? node, string path, List refs, HashSet seen) + private static void WalkAndCollectRefs(JsonNode? node, string path, List refs, HashSet seen, int depth) { - if (node == null) return; + if (node == null || depth >= GtsConstants.MaxNestingDepth) return; if (node is JsonValue value && value.GetValueKind() == JsonValueKind.String) { @@ -352,7 +330,7 @@ private static void WalkAndCollectRefs(JsonNode? node, string path, List - - net8.0 - enable - enable - - - diff --git a/Gts/GtsConstants.cs b/Gts/GtsConstants.cs new file mode 100644 index 0000000..5c4ee4d --- /dev/null +++ b/Gts/GtsConstants.cs @@ -0,0 +1,29 @@ +namespace Gts; + +/// +/// Central definitions for the GTS identifier prefix, the gts:// URI form, and shared +/// traversal limits. Centralizing these avoids the magic literals (notably the [6..] slice +/// that hard-codes "gts://".Length) that were previously scattered across the codebase. +/// +public static class GtsConstants +{ + /// The canonical GTS identifier prefix (every GTS id starts with this). + public const string IdPrefix = "gts."; + + /// The gts:// URI form used in JSON Schema $id/$ref values. + public const string UriPrefix = "gts://"; + + /// + /// Maximum nesting depth for recursive traversal of untrusted JSON/JSON Schema documents. + /// Acts as a backstop against from pathologically or + /// maliciously deep input; chosen high enough not to reject realistic documents. + /// + public const int MaxNestingDepth = 512; + + /// + /// Returns with a leading removed, or the value + /// unchanged when it does not start with the prefix. + /// + public static string StripUriPrefix(string value) => + value.StartsWith(UriPrefix, System.StringComparison.Ordinal) ? value[UriPrefix.Length..] : value; +} diff --git a/Gts/GtsId.cs b/Gts/GtsId.cs index 0dac944..733e39b 100644 --- a/Gts/GtsId.cs +++ b/Gts/GtsId.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text.RegularExpressions; using Gts.Parsing; using Gts.Utils; @@ -13,38 +14,38 @@ public sealed class GtsId { /// Maximum allowed length of a GTS identifier string. public const int MaxLength = 1024; - + /// /// The canonical identifier string (lowercase, trimmed). /// public string Id { get; } - + /// True if this ID is a type identifier (ends with ~). public bool IsType { get; private set; } - + /// True if this ID is an instance identifier (does not end with ~). public bool IsInstance { get; private set; } - + /// True if this ID was parsed as a pattern (may contain wildcards). public bool IsPattern { get; private set; } - + /// /// Parsed segments (vendor.package.namespace.type.version per segment). /// public IReadOnlyCollection Segments { get; } - + /// Creates a GTS ID from a canonical string and parsed segments. internal GtsId(string id, IReadOnlyCollection segments) { Id = id; Segments = segments; } - + /// Parses a GTS type or instance ID; throws on failure. public static GtsId Parse(string id) { var parseResult = TryParseInternal(id, out GtsId? result); - + if (parseResult) { return result!; @@ -52,10 +53,10 @@ public static GtsId Parse(string id) throw new ParseException(parseResult); } - + /// Attempts to parse a GTS type or instance ID without throwing. public static ParseResult TryParse(string? id, out GtsId? result) - { + { return TryParseInternal(id, out result); } @@ -63,7 +64,7 @@ public static ParseResult TryParse(string? id, out GtsId? result) public static GtsId ParsePattern(string pattern) { var parseResult = TryParsePatternInternal(pattern, out GtsId? result); - + if (parseResult) { return result!; @@ -83,45 +84,13 @@ private static ParseResult TryParseInternal(string? id, out GtsId? result) result = null; if (id is null) return ParseResult.ArgumentIsNull; - - var value = id.StartsWith("gts://", StringComparison.Ordinal) ? id[6..] : id; - if (value.Length == 0 || value.Length > MaxLength || value != value.ToLowerInvariant() || - !value.StartsWith("gts.", StringComparison.Ordinal)) - return new ParseResult(); - - var isType = value.EndsWith('~'); - var body = value[4..]; - var parts = body.Split('~'); - if (isType) - parts = parts[..^1]; - if (parts.Length == 0 || parts.Any(string.IsNullOrEmpty)) + if (!GtsIdParser.TryParse(id, MaxLength, out var parsed) || parsed is null) return new ParseResult(); - var uuidTail = !isType && parts.Length >= 2 && Guid.TryParseExact(parts[^1], "D", out _); - var segmentCount = uuidTail ? parts.Length - 1 : parts.Length; - var segments = new List(); - for (var i = 0; i < segmentCount; i++) - { - var tokens = parts[i].Split('.'); - if (tokens.Length is not (5 or 6) || tokens.Take(4).Any(t => !Regex.IsMatch(t, "^[a-z_][a-z0-9_]*$")) || - !Regex.IsMatch(tokens[4], "^v(0|[1-9][0-9]*)$") || - tokens.Length == 6 && !Regex.IsMatch(tokens[5], "^(0|[1-9][0-9]*)$")) - return new ParseResult(); - - segments.Add(new GtsIdSegment( - tokens[0], tokens[1], tokens[2], tokens[3], int.Parse(tokens[4][1..]), - tokens.Length == 6 ? int.Parse(tokens[5]) : null, - i < segmentCount - 1 || isType || uuidTail, - false)); - } - - if (uuidTail) - segments.Add(new GtsIdSegment(null, null, null, null, null, null, false, false)); - - result = new GtsId(value, segments) + result = new GtsId(parsed.CanonicalId, parsed.Segments) { - IsType = isType, - IsInstance = !isType, + IsType = parsed.IsType, + IsInstance = !parsed.IsType, IsPattern = false }; return ParseResult.Success; @@ -134,12 +103,11 @@ private static ParseResult TryParsePatternInternal(string? pattern, out GtsId? r result = null; return ParseResult.ArgumentIsNull; } - + var parseResult = Parsers.GtsPattern.Parse(pattern); - + if (!parseResult.Success) { - // TODO: add errors to the result result = null; return new ParseResult(); } @@ -161,72 +129,64 @@ private static GtsIdSegment MapSegment(Parsers.SegmentInfo s) s.Vendor, s.Package, s.Namespace, s.Type, s.Version?.Major, s.Version?.Minor, true, s.IsWildcard); } - /// - /// Returns true if this identifier matches the given pattern. - /// Pattern may contain at most one trailing wildcard (*); matching is segment-by-segment. - /// - public bool Matches(GtsId pattern) + public static bool TryMatch(string candidate, string pattern, out bool match) { - if (pattern is null) return false; - - // TODO: counting is probably not needed - if (!pattern.Id.Contains('*')) - return MatchSegments(pattern.Segments, Segments, true); - - if (pattern.Id.Count(c => c == '*') > 1 || !pattern.Id.EndsWith('*')) + match = false; + if (string.IsNullOrEmpty(candidate) || string.IsNullOrEmpty(pattern)) + return false; + if (candidate.Contains('*') && (candidate.Count(character => character == '*') != 1 || !candidate.EndsWith('*') || !TryParsePattern(candidate, out _))) + return false; + if (!candidate.Contains('*') && !TryParse(candidate, out _)) + return false; + if (pattern.Contains('*') && (pattern.Count(character => character == '*') != 1 || !pattern.EndsWith('*') || pattern.Length < 2 || pattern[^2] is not ('.' or '~'))) + return false; + if (!pattern.Contains('*') && !TryParse(pattern, out _)) return false; - return MatchSegments(pattern.Segments, Segments, false); + match = PatternRegex(pattern).IsMatch(candidate); + return true; } + /// + /// Returns true if this identifier matches the given pattern. + /// Pattern may contain at most one trailing wildcard (*). + /// + public bool Matches(GtsId pattern) => pattern is not null && Matches(pattern.Id); + /// /// Returns true if this identifier matches the given pattern string. /// Pattern may contain at most one trailing wildcard (*). /// public bool Matches(string pattern) { - if (string.IsNullOrEmpty(pattern)) return false; - // TODO: throwing is probably more idiomatic - if (!TryParsePattern(pattern, out var patternId)) return false; - return Matches(patternId!); + // A `~*` pattern matches descendants of the type, not the bare type identifier itself. + if (pattern.EndsWith("~*", StringComparison.Ordinal) && Id == pattern[..^1]) + return false; + return TryMatch(Id, pattern, out var match) && match; } - private static bool MatchSegments( - IReadOnlyCollection patternSegs, IReadOnlyCollection candidateSegs, bool exact) - { - if (exact && patternSegs.Count != candidateSegs.Count) return false; - if (patternSegs.Count > candidateSegs.Count) return false; + // Compiled-pattern cache. All GTS matching flows (Matches, TryMatch, and the CLI/HTTP match + // operations) resolve through a single regex translation, so behavior cannot drift between two + // implementations, and the (previously per-call) regex compilation is amortized across calls. + private static readonly ConcurrentDictionary PatternRegexCache = new(StringComparer.Ordinal); - var patternList = patternSegs as IList ?? patternSegs.ToList(); - var candidateList = candidateSegs as IList ?? candidateSegs.ToList(); + private static Regex PatternRegex(string pattern) + { + // Bound the cache: patterns can come from untrusted input, so cap growth (see GtsJsonSchemaEngine). + if (PatternRegexCache.Count >= 1024) + PatternRegexCache.Clear(); - for (var i = 0; i < patternList.Count; i++) + return PatternRegexCache.GetOrAdd(pattern, static p => { - var pSeg = patternList[i]; - var cSeg = candidateList[i]; - - if (pSeg.IsWildcard) - { - if (pSeg.Vendor is not null && pSeg.Vendor != cSeg.Vendor) return false; - if (pSeg.Package is not null && pSeg.Package != cSeg.Package) return false; - if (pSeg.Namespace is not null && pSeg.Namespace != cSeg.Namespace) return false; - if (pSeg.Type is not null && pSeg.Type != cSeg.Type) return false; - - if (pSeg.VersionMajor.HasValue && pSeg.VersionMajor != cSeg.VersionMajor) return false; - if (pSeg.VersionMinor.HasValue && (cSeg.VersionMinor is null || pSeg.VersionMinor != cSeg.VersionMinor)) return false; - - return true; - } - - if (pSeg.Vendor != cSeg.Vendor) return false; - if (pSeg.Package != cSeg.Package) return false; - if (pSeg.Namespace != cSeg.Namespace) return false; - if (pSeg.Type != cSeg.Type) return false; - if (pSeg.VersionMajor != cSeg.VersionMajor) return false; - if (pSeg.VersionMinor.HasValue && (cSeg.VersionMinor is null || pSeg.VersionMinor != cSeg.VersionMinor)) return false; - } - - return true; + var expression = Regex.Escape(p); + expression = Regex.Replace(expression, @"v([0-9]+)~", "v$1(?:\\.[0-9]+)?~"); + expression = Regex.Replace(expression, @"v([0-9]+)$", "v$1(?:\\.[0-9]+)?"); + var wildcard = p.EndsWith('*'); + if (wildcard) expression = expression[..^2] + ".*"; + return new Regex( + "^" + expression + (wildcard || p.EndsWith('~') ? "" : "$"), + RegexOptions.CultureInvariant | RegexOptions.Compiled); + }); } /// diff --git a/Gts/GtsIdSegment.cs b/Gts/GtsIdSegment.cs index c36b27e..c5b4a4b 100644 --- a/Gts/GtsIdSegment.cs +++ b/Gts/GtsIdSegment.cs @@ -34,30 +34,30 @@ internal GtsIdSegment( internal GtsIdSegment() { } - + //public string? Segment { get; internal set; } - + /// Vendor part of the segment. public string? Vendor { get; internal set; } - + /// Package part of the segment. public string? Package { get; internal set; } - + /// Namespace part of the segment. public string? Namespace { get; internal set; } - + /// Type part of the segment. public string? Type { get; internal set; } - + /// Major version component, or null if wildcard. public int? VersionMajor { get; internal set; } - + /// Minor version component, or null if not specified. public int? VersionMinor { get; internal set; } - + /// True if this segment is from a type ID (trailing ~). public bool IsType { get; internal set; } - + /// True if this segment is a pattern wildcard. public bool IsWildcard { get; internal set; } @@ -100,7 +100,7 @@ public override string ToString() { sb.Append('v'); sb.Append(VersionMajor.Value); - + if (VersionMinor.HasValue) { sb.Append('.'); diff --git a/Gts/Parsing/GtsIdParser.cs b/Gts/Parsing/GtsIdParser.cs new file mode 100644 index 0000000..61c633c --- /dev/null +++ b/Gts/Parsing/GtsIdParser.cs @@ -0,0 +1,66 @@ +using Pidgin; + +namespace Gts.Parsing; + +internal sealed record ParsedGtsId(string CanonicalId, IReadOnlyList Segments, bool IsType, bool HasUuidTail); + +internal static class GtsIdParser +{ + internal static bool TryParse(string? input, int maxLength, out ParsedGtsId? parsed) + { + parsed = null; + if (input is null) + return false; + // Validate the original input: a GtsId is always the bare canonical form ("gts.…"). + // The "gts://" URI form is a JSON Schema serialization detail ($id/$ref) and is stripped + // by those URI-specific callers before they reach here, matching the gts-rust/gts-go + // reference implementations. Accepting it here makes GtsId.Id disagree with the input and + // lets URI-form values leak into query/validation paths that compare against canonical ids. + var value = input; + if (value.Length == 0 || value.Length > maxLength || value != value.ToLowerInvariant() || !value.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal)) + return false; + + var isType = value.EndsWith('~'); + var parts = value[4..].Split('~'); + if (isType) + parts = parts[..^1]; + if (parts.Length == 0 || parts.Any(string.IsNullOrEmpty)) + return false; + + var hasUuidTail = !isType && parts.Length >= 2 && Guid.TryParseExact(parts[^1], "D", out _); + var segmentCount = hasUuidTail ? parts.Length - 1 : parts.Length; + var segments = new List(parts.Length); + for (var index = 0; index < segmentCount; index++) + { + var result = Parsers.Segment.Parse(parts[index]); + if (!result.Success || !HasCanonicalVersion(parts[index], result.Value.Version)) + return false; + var segment = result.Value; + segments.Add(new GtsIdSegment( + segment.Vendor, + segment.Package, + segment.Namespace, + segment.Type, + segment.Version?.Major, + segment.Version?.Minor, + index < segmentCount - 1 || isType || hasUuidTail, + false)); + } + + if (hasUuidTail) + segments.Add(new GtsIdSegment(null, null, null, null, null, null, false, false)); + + parsed = new ParsedGtsId(value, segments, isType, hasUuidTail); + return true; + } + + private static bool HasCanonicalVersion(string source, Parsers.VersionInfo? version) + { + if (version is null || version.Value.Major < 0 || version.Value.Minor is < 0) + return false; + var tokens = source.Split('.'); + if (tokens.Length is not (5 or 6) || tokens[4] != $"v{version.Value.Major}") + return false; + return tokens.Length == 5 || tokens[5] == version.Value.Minor?.ToString(); + } +} \ No newline at end of file diff --git a/Gts/Parsing/ParseResult.cs b/Gts/Parsing/ParseResult.cs index 546d54c..57bcf19 100644 --- a/Gts/Parsing/ParseResult.cs +++ b/Gts/Parsing/ParseResult.cs @@ -7,7 +7,7 @@ public sealed class ParseResult public static readonly ParseResult Success = new(); /// Indicates null input was passed. public static readonly ParseResult ArgumentIsNull = new(); // TODO: actual error message - + /// Returns true when this result represents success. public static implicit operator bool(ParseResult result) { diff --git a/Gts/Parsing/Parsers.cs b/Gts/Parsing/Parsers.cs index f8f73d9..7fa7c66 100644 --- a/Gts/Parsing/Parsers.cs +++ b/Gts/Parsing/Parsers.cs @@ -15,7 +15,7 @@ internal static class Parsers internal static readonly Parser Wildcard = String("*").Labelled(nameof(Wildcard)); - + internal static readonly Parser V = Char('v').Labelled(nameof(V)); @@ -53,10 +53,10 @@ internal static class Parsers internal static readonly Parser IdentifierOrWildcard = Wildcard.Or(Identifier) .Labelled(nameof(Identifier)); - + internal static readonly Parser GtsPrefix = String("gts").Labelled(nameof(GtsPrefix)); - + internal static readonly Parser Vendor = Identifier.Labelled(nameof(Vendor)); internal static readonly Parser Package = @@ -65,7 +65,7 @@ internal static class Parsers Identifier.Labelled(nameof(Namespace)); internal static readonly Parser Type = Identifier.Labelled(nameof(Type)); - + internal static readonly Parser Segment = Vendor.Before(Dot) .Then(Package.Before(Dot), (vendor, package) => (vendor, package)) @@ -80,7 +80,7 @@ internal static class Parsers version, false)) .Labelled(nameof(Segment)); - + internal static readonly Parser Pattern = Vendor.Before(Dot).Optional() .Then(Package.Before(Dot).Optional(), (vendor, package) => (vendor, package)) @@ -117,7 +117,7 @@ version is "*" .Then(Segment.SeparatedAtLeastOnce(Tilde), (_, segments) => new IdentifierInfo(IdentifierKind.Instance, segments)) .Before(End); - + internal static readonly Parser GtsPattern = GtsPrefix.Then(Dot) .Then(Pattern.SeparatedAndOptionallyTerminatedAtLeastOnce(Tilde), @@ -160,7 +160,7 @@ internal enum IdentifierKind Instance, Pattern, } - + /// Parsed identifier with kind and segment list. internal record struct IdentifierInfo( IdentifierKind Kind, IEnumerable Segments) : IEnumerable @@ -171,7 +171,7 @@ internal record struct IdentifierInfo( public bool IsInstance => Kind == IdentifierKind.Instance; /// True if this is a pattern. public bool IsPattern => Kind == IdentifierKind.Pattern; - + /// public IEnumerator GetEnumerator() => Segments.GetEnumerator(); /// diff --git a/Gts/Utils/GuidUtils.cs b/Gts/Utils/GuidUtils.cs index 8b4c081..d86cbb4 100644 --- a/Gts/Utils/GuidUtils.cs +++ b/Gts/Utils/GuidUtils.cs @@ -8,14 +8,14 @@ namespace Gts.Utils; /// GTS uses namespace UUID(NAMESPACE_URL, "gts") and name = the GTS identifier string. /// internal static class GuidUtils -{ +{ /// /// GTS namespace: uuid5(NAMESPACE_URL, "gts") as per spec. /// RFC 4122 namespace for URLs /// public static readonly Guid GtsNamespace = Create( new("6ba7b811-9dad-11d1-80b4-00c04fd430c8"), "gts"); - + /// /// Creates a UUID v5 from a namespace and name (UTF-8 bytes are hashed). /// diff --git a/README.md b/README.md index d2321b3..3fbc107 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An idiomatic C#/.NET library for working with **GTS** ([Global Type System](https://github.com/gts-spec/gts-spec)) identifiers and JSON/JSON Schema artifacts. -Supported GTS spec version: `v0.14.1` (pinned in [`.gts-spec-version`](.gts-spec-version)) +Supported GTS spec version: `v0.14.4` (pinned in [`.gts-spec-version`](.gts-spec-version)) ## Roadmap