From 1175893bbb633ad826847a4e5620cf5d8e6f4ecf Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:58:29 +0300 Subject: [PATCH 01/17] chore: roll spec pin to v0.14.3 and centralize build config Bump the conformance target in .gts-spec-version to v0.14.3 (README updated to match) and introduce a solution-wide Directory.Build.props that owns the shared TargetFramework/Nullable/ImplicitUsings settings and pins the package Version at 0.1.0. Per-project csproj files drop the now-redundant properties. Gts.Store gains the JsonPointer.Net reference and Gts drops its explicit System.Text.Json package (provided by the shared framework on net8.0). The Gts.Store assembly now exposes internals to Gts.Tests for unit coverage. Signed-off-by: Artifizer --- .gts-spec-version | 2 +- Directory.Build.props | 12 ++++++++++++ Gts.Application/Gts.Application.csproj | 3 --- Gts.Cli/Gts.Cli.csproj | 3 --- Gts.Server/Gts.Server.csproj | 3 --- Gts.Store/Gts.Store.csproj | 7 +------ Gts.Store/Properties/AssemblyInfo.cs | 1 + Gts.Tests/Gts.Tests.csproj | 4 ---- Gts/Gts.csproj | 7 ------- README.md | 2 +- 10 files changed, 16 insertions(+), 28 deletions(-) create mode 100644 Directory.Build.props diff --git a/.gts-spec-version b/.gts-spec-version index b0d2e47..818cabd 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.14.2 +v0.14.3 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.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..2b8a8d4 100644 --- a/Gts.Store/Gts.Store.csproj +++ b/Gts.Store/Gts.Store.csproj @@ -1,16 +1,11 @@  - - net8.0 - enable - enable - - + 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.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/Gts.csproj b/Gts/Gts.csproj index 21d472c..7de4606 100644 --- a/Gts/Gts.csproj +++ b/Gts/Gts.csproj @@ -1,14 +1,7 @@  - - net8.0 - enable - enable - - - diff --git a/README.md b/README.md index d2321b3..f6782fb 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.3` (pinned in [`.gts-spec-version`](.gts-spec-version)) ## Roadmap From e4efe9453dda0682a3528fa4e64e5c516348fd1c Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:58:35 +0300 Subject: [PATCH 02/17] refactor(id): centralize GTS id constants and parsing Add GtsConstants for the shared "gts."/"gts://" prefixes and traversal limits so the magic literals (notably the "gts://".Length slice) no longer appear inline across the codebase, and extract identifier decomposition into a dedicated GtsIdParser (ParsedGtsId). GtsId and the JSON entity extraction are reworked to build on these, keeping segment parsing in one canonical place. Signed-off-by: Artifizer --- Gts/Extraction/GtsJsonEntity.cs | 42 ++------- Gts/GtsConstants.cs | 29 ++++++ Gts/GtsId.cs | 160 ++++++++++++-------------------- Gts/Parsing/GtsIdParser.cs | 61 ++++++++++++ 4 files changed, 160 insertions(+), 132 deletions(-) create mode 100644 Gts/GtsConstants.cs create mode 100644 Gts/Parsing/GtsIdParser.cs 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 +/// 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/Parsing/GtsIdParser.cs b/Gts/Parsing/GtsIdParser.cs new file mode 100644 index 0000000..4a2966e --- /dev/null +++ b/Gts/Parsing/GtsIdParser.cs @@ -0,0 +1,61 @@ +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; + var value = GtsConstants.StripUriPrefix(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 From 065d45bb988ebdd218a7716c6d28c1bdd2486a6d Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:58:43 +0300 Subject: [PATCH 03/17] feat(store): add self-contained JSON Schema evaluation engine Introduce an in-tree JSON Schema engine (GtsJsonSchemaEngine behind IGtsJsonSchemaEngine) with supporting JSON helpers (GtsJson, GtsJsonPointer), a format registry/validator (GtsFormatRegistry, GtsFormatValidator), and route the evaluator, document normalizer, and minor-version canonicalizer through it. This gives GTS control over dialect handling and format checks instead of relying solely on the third-party validator. Covered by JsonInfrastructureTests. Signed-off-by: Artifizer --- Gts.Store/GtsFormatValidator.cs | 1 + Gts.Store/Validation/GtsFormatRegistry.cs | 76 ++++++++ Gts.Store/Validation/GtsJson.cs | 11 ++ Gts.Store/Validation/GtsJsonPointer.cs | 20 +++ Gts.Store/Validation/GtsJsonSchemaEngine.cs | 169 ++++++++++++++++++ .../Validation/GtsJsonSchemaEvaluator.cs | 68 +------ .../Validation/GtsSchemaDocumentNormalizer.cs | 127 ++++++++----- .../GtsSchemaMinorVersionCanonicalizer.cs | 36 ++-- Gts.Store/Validation/IGtsJsonSchemaEngine.cs | 13 ++ .../Validation/JsonInfrastructureTests.cs | 52 ++++++ 10 files changed, 452 insertions(+), 121 deletions(-) create mode 100644 Gts.Store/GtsFormatValidator.cs create mode 100644 Gts.Store/Validation/GtsFormatRegistry.cs create mode 100644 Gts.Store/Validation/GtsJson.cs create mode 100644 Gts.Store/Validation/GtsJsonPointer.cs create mode 100644 Gts.Store/Validation/GtsJsonSchemaEngine.cs create mode 100644 Gts.Store/Validation/IGtsJsonSchemaEngine.cs create mode 100644 Gts.Tests/Validation/JsonInfrastructureTests.cs 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/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..c5bf0ad --- /dev/null +++ b/Gts.Store/Validation/GtsJsonSchemaEngine.cs @@ -0,0 +1,169 @@ +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)); + var schema = GetOrCompile(fingerprint, () => Compile(rootSchemaId, root, schemas)); + 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..45d8dfa 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,60 @@ 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; + foreach (var child in branches) + StripXGtsRefDeep(child); + if (branches.Count > 0 && branches.All(branch => branch is JsonObject branchObject && branchObject.Count == 0)) + obj.Remove(keyword); } } + 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 +93,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/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 From d71fd8acc2925dc1b0c35a28e98ce3f4cd1591cc Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:58:48 +0300 Subject: [PATCH 04/17] refactor(store): introduce typed validation failures Replace the stringly-typed FailureReason fields on the validation, cast, and schema result records with a closed GtsValidationFailure set, exposing a ToWire() helper for the external string form. Producers and consumers now share one enumerated set of reasons instead of matching on magic strings. Signed-off-by: Artifizer --- Gts.Store/GtsInstanceCastResult.cs | 2 +- Gts.Store/GtsInstanceValidationResult.cs | 2 +- Gts.Store/GtsSchemaValidationResult.cs | 2 +- Gts.Store/GtsValidationFailure.cs | 57 ++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 Gts.Store/GtsValidationFailure.cs 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/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/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(), + }; +} From 7b5999153ad1b1094ca75e13f3234237949ad9f2 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:58:58 +0300 Subject: [PATCH 05/17] refactor(store): decompose registry into focused services Break the monolithic GtsRegistry apart into single-responsibility services: schema validation (GtsSchemaValidationService, GtsTypeSchema, keyword/traits validators, GtsSchemaKeywords), instance validation (GtsInstanceValidationService), casting (GtsCastService), compatibility (GtsSchemaCompatibilityService), and x-gts-ref handling (GtsRefValidator, GtsRefConstraint, GtsRefValidationMode, GtsSchemaDependencyGraph, GtsTraitComposer). GtsRegistry and the in-memory registry/store are reduced to thin orchestrators over these services; the derivation, ref-format, evolution, and minor-version compatibility helpers and IGtsStore are updated to the new shape. Snapshot handling in the in-memory store is hardened alongside. Signed-off-by: Artifizer --- Gts.Store/GtsCastService.cs | 70 +++ Gts.Store/GtsInstanceCast.cs | 7 +- Gts.Store/GtsInstanceValidationService.cs | 113 ++++ .../GtsJsonSchemaEvolutionCompatibility.cs | 103 +++- Gts.Store/GtsQuery.cs | 9 +- Gts.Store/GtsRefConstraint.cs | 27 + Gts.Store/GtsRefValidationMode.cs | 63 +++ Gts.Store/GtsRefValidator.cs | 288 ++++++++++ Gts.Store/GtsRegistry.cs | 492 +++--------------- Gts.Store/GtsSchemaCompatibilityService.cs | 20 + Gts.Store/GtsSchemaDependencyGraph.cs | 113 ++++ Gts.Store/GtsSchemaDerivationValidator.cs | 269 +++++++++- Gts.Store/GtsSchemaKeywordValidator.cs | 83 +++ Gts.Store/GtsSchemaKeywords.cs | 26 + .../GtsSchemaMinorVersionCompatibility.cs | 3 +- Gts.Store/GtsSchemaRefFormatValidator.cs | 28 +- Gts.Store/GtsSchemaTraitsValidator.cs | 317 +++++++++++ Gts.Store/GtsSchemaValidationService.cs | 99 ++++ Gts.Store/GtsTraitComposer.cs | 73 +++ Gts.Store/GtsTypeSchema.cs | 109 ++++ Gts.Store/IGtsStore.cs | 29 +- Gts.Store/InMemory/InMemoryGtsRegistry.cs | 19 +- Gts.Store/InMemory/InMemoryGtsStore.cs | 95 +++- 23 files changed, 1925 insertions(+), 530 deletions(-) create mode 100644 Gts.Store/GtsCastService.cs create mode 100644 Gts.Store/GtsInstanceValidationService.cs create mode 100644 Gts.Store/GtsRefConstraint.cs create mode 100644 Gts.Store/GtsRefValidationMode.cs create mode 100644 Gts.Store/GtsRefValidator.cs create mode 100644 Gts.Store/GtsSchemaCompatibilityService.cs create mode 100644 Gts.Store/GtsSchemaDependencyGraph.cs create mode 100644 Gts.Store/GtsSchemaKeywordValidator.cs create mode 100644 Gts.Store/GtsSchemaKeywords.cs create mode 100644 Gts.Store/GtsSchemaTraitsValidator.cs create mode 100644 Gts.Store/GtsSchemaValidationService.cs create mode 100644 Gts.Store/GtsTraitComposer.cs create mode 100644 Gts.Store/GtsTypeSchema.cs diff --git a/Gts.Store/GtsCastService.cs b/Gts.Store/GtsCastService.cs new file mode 100644 index 0000000..d7f3a3f --- /dev/null +++ b/Gts.Store/GtsCastService.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store.Validation; + +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); + var evaluation = GtsJsonSchemaEvaluator.Evaluate(casted, targetSchemaId, schemas); + 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/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/GtsInstanceValidationService.cs b/Gts.Store/GtsInstanceValidationService.cs new file mode 100644 index 0000000..7ec9853 --- /dev/null +++ b/Gts.Store/GtsInstanceValidationService.cs @@ -0,0 +1,113 @@ +using System.Text.Json.Nodes; +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); + 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) + }; + } + + 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..247aeef --- /dev/null +++ b/Gts.Store/GtsRefConstraint.cs @@ -0,0 +1,27 @@ +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; + return Pattern.EndsWith('*') + ? value.StartsWith(Pattern[..^1], StringComparison.Ordinal) + : value.StartsWith(Pattern, StringComparison.Ordinal); + } +} \ 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..12807a4 --- /dev/null +++ b/Gts.Store/GtsRefValidator.cs @@ -0,0 +1,288 @@ +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; + var branchResults = new List>(); + foreach (var branch in branches) + { + var branchErrors = new List(); + WalkInstance(instance, branch, root, selectedTypeId, context, mode, path, branchErrors, new HashSet(localRefs), depth + 1); + branchResults.Add(branchErrors); + } + var matching = branchResults.Count(result => result.Count == 0); + if (branchResults.Count > 0 && (matching == 0 || 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); + + /// + /// 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..7cb2e86 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,15 @@ public ValueTask> GetAllAsync() return _store.GetAllAsync(); } + /// + /// 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. + /// + public ValueTask> SnapshotForReadAsync() + { + return _store.SnapshotForReadAsync(); + } + /// Returns the number of entities in the registry. public ValueTask CountAsync() { @@ -74,7 +97,9 @@ 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). + var all = await _store.SnapshotForReadAsync().ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); var results = GtsQuery.Execute(all, parsed, lim, cancellationToken); return GtsQueryExecutionResult.Success(lim, results); @@ -147,109 +172,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" }; + CancellationToken cancellationToken = default, + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default) => + _instanceValidation.ValidateStoredAsync(instanceId, cancellationToken, refValidationMode); - 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) - }; - } - } - - 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 +192,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 +204,23 @@ 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) => + _schemaValidation.ValidateAsync(schemaTypeId, schemaDocument, cancellationToken, refValidationMode); /// /// 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 +296,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" - }; - } - - 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) - }; - } - } + CancellationToken cancellationToken = default) => + _casting.CastAsync(instanceId, toSchemaId, cancellationToken); - 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..d72968d --- /dev/null +++ b/Gts.Store/GtsSchemaDependencyGraph.cs @@ -0,0 +1,113 @@ +using System.Text.Json.Nodes; + +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)) + { + var id = GtsConstants.StripUriPrefix(reference).Split('#')[0]; + if (GtsId.TryParse(id, out var parsed) && parsed is not null && stack.Add(id) && load(parsed) is JsonObject target) + { + var resolved = Resolve(target, load, stack, depth + 1); + stack.Remove(id); + return resolved; + } + } + 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/GtsSchemaValidationService.cs b/Gts.Store/GtsSchemaValidationService.cs new file mode 100644 index 0000000..7be82fb --- /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) + { + 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().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/IGtsStore.cs b/Gts.Store/IGtsStore.cs index dfd8aed..4b5599f 100644 --- a/Gts.Store/IGtsStore.cs +++ b/Gts.Store/IGtsStore.cs @@ -2,11 +2,31 @@ 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); @@ -18,9 +38,14 @@ public interface IGtsStore /// 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). The returned entities and their + /// must not be mutated. Prefer this over on hot paths that only read. + /// + ValueTask> SnapshotForReadAsync(); /// 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..9caeab0 100644 --- a/Gts.Store/InMemory/InMemoryGtsStore.cs +++ b/Gts.Store/InMemory/InMemoryGtsStore.cs @@ -1,15 +1,17 @@ -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); private readonly object _sync = new(); /// @@ -17,28 +19,66 @@ 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 +125,23 @@ public ValueTask> GetAllAsync() } } + /// + public ValueTask> SnapshotForReadAsync() + { + 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 CountAsync() { From 1cd7b55761cf8e1fc153722b68b57985c3c91642 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:59:04 +0300 Subject: [PATCH 06/17] refactor(server): split HTTP API into contracts, endpoints, and helpers Carve the oversized GtsHttpApiExtensions into focused parts: request/response DTOs in GtsHttpContracts, endpoint wiring in GtsOperationEndpoints, and shared logic in the GtsHttpApiHelpers partial. Entity operations and registry bootstrap are updated to the decomposed store services and typed failures. Signed-off-by: Artifizer --- Gts.Application/GtsEntityOperations.cs | 84 ++++---- Gts.Application/GtsHttpApiExtensions.cs | 250 ++++------------------- Gts.Application/GtsHttpApiHelpers.cs | 79 +++++++ Gts.Application/GtsHttpContracts.cs | 20 ++ Gts.Application/GtsOperationEndpoints.cs | 131 ++++++++++++ Gts.Application/GtsRegistryBootstrap.cs | 16 +- 6 files changed, 326 insertions(+), 254 deletions(-) create mode 100644 Gts.Application/GtsHttpApiHelpers.cs create mode 100644 Gts.Application/GtsHttpContracts.cs create mode 100644 Gts.Application/GtsOperationEndpoints.cs diff --git a/Gts.Application/GtsEntityOperations.cs b/Gts.Application/GtsEntityOperations.cs index 810ef3a..9d95257 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,64 @@ 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) - { - error = null; - if (!body.TryGetPropertyValue("$schema", out var node) || node is not JsonValue value || - !value.TryGetValue(out var dialect) || string.IsNullOrEmpty(dialect)) - { - error = "Schema must contain a supported $schema dialect"; - return false; - } - - 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; - } } diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs index a122431..ab3aec3 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) => @@ -104,25 +116,10 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi continue; } - if (!TryGetString(schema, "$schema", out _)) - { - allOk = false; - results.Add(new { ok = false, type_id = (string?)null, error = "GTS Type Schema must contain a top-level $schema field" }); - continue; - } - - if (!TryGetString(schema, "$id", out var embeddedId) || !embeddedId.StartsWith("gts://", StringComparison.Ordinal)) - { - 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 typeId = embeddedId["gts://".Length..]; - if (!GtsId.TryParse(typeId, out var parsed) || parsed is null || !parsed.IsType) + if (!GtsTypeSchema.TryGetDeclaredTypeId(schema, out var typeId, out var identityError)) { allOk = false; - results.Add(new { ok = false, type_id = typeId, error = $"Invalid GTS Type Schema $id: '{embeddedId}'" }); + results.Add(new { ok = false, type_id = (string?)null, error = identityError }); continue; } @@ -226,26 +223,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 +243,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 +264,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 +290,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 +345,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 +367,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; + } } } } From 0b16142f7569f0c09a4b918be7dae7c47c9d2a8e Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 01:59:09 +0300 Subject: [PATCH 07/17] test(store): update CLI and validation tests to new services Point the CLI at GtsSchemaCompatibilityService.CompareEvolution and the typed FailureReason.ToWire()/DeepClone() paths, and refresh the instance-cast, instance-validation, and schema-validation tests for the decomposed store services and typed validation failures. Signed-off-by: Artifizer --- Gts.Cli/Program.cs | 13 ++++++------- Gts.Tests/Validation/InstanceCastTests.cs | 16 +++++++++++----- Gts.Tests/Validation/InstanceValidationTests.cs | 6 +++--- Gts.Tests/Validation/SchemaValidationTests.cs | 12 ++++++------ 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Gts.Cli/Program.cs b/Gts.Cli/Program.cs index c3c255a..43d666d 100644 --- a/Gts.Cli/Program.cs +++ b/Gts.Cli/Program.cs @@ -428,7 +428,7 @@ private static async Task RunValidateAsync(string[] args) var reg = await CreateRegistryAsync().ConfigureAwait(false); var r = await reg.ValidateInstanceAsync(instanceId).ConfigureAwait(false); - WriteJson(new { id = instanceId, ok = r.Ok, error = r.Ok ? "" : (r.FailureReason ?? "validation failed") }); + WriteJson(new { id = instanceId, ok = r.Ok, error = r.Ok ? "" : (r.FailureReason?.ToWire() ?? "validation failed") }); return 0; } @@ -492,8 +492,7 @@ private static async Task RunCompatibilityAsync(string[] args) 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); + var (backOk, backErr, fwdOk, fwdErr) = GtsSchemaCompatibilityService.CompareEvolution(oldFlat, newFlat); WriteJson(new { @@ -530,12 +529,12 @@ private static async Task RunCastAsync(string[] args) { WriteJson(new { - error = result.FailureReason, + error = 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 is null ? null : JsonNode.Parse(result.CastedContent.ToJsonString()), + casted_entity = result.CastedContent?.DeepClone(), comparison = result.Comparison is null ? null : new @@ -551,7 +550,7 @@ private static async Task RunCastAsync(string[] args) WriteJson(new { - casted_entity = JsonNode.Parse(result.CastedContent!.ToJsonString()), + casted_entity = result.CastedContent!.DeepClone(), is_backward_compatible = result.Comparison!.IsBackwardEvolutionCompatible, is_forward_compatible = result.Comparison.IsForwardEvolutionCompatible, is_structurally_compatible = result.Comparison.IsStructurallyCompatible @@ -604,7 +603,7 @@ JsonValue jv when jv.TryGetValue(out var nd) => nd, JsonValue jv when jv.TryGetValue(out var nm) => nm, JsonValue jv when jv.TryGetValue(out var nl) => nl, null => null!, - _ => JsonNode.Parse(r.Value!.ToJsonString())! + _ => r.Value!.DeepClone() }; WriteJson(new { resolved = true, value = payload }); return 0; 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..fbae9b0 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,6 @@ 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); } } diff --git a/Gts.Tests/Validation/SchemaValidationTests.cs b/Gts.Tests/Validation/SchemaValidationTests.cs index 38fe937..4cbac28 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,7 @@ 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] @@ -187,6 +187,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); } } From 703be5ebdd555b31887aab4c1d3fdd2cdffa03c6 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 11:45:59 +0300 Subject: [PATCH 08/17] test: guard ancestor cross-dialect $ref rejection Add a regression test proving a descendant, and an instance of it, is rejected when an ancestor holds a cross-dialect gts:// $ref the descendant does not reference. ValidateAgainstRegistry already validates each chain member's reference targets, so no production change is needed; the test pins that behavior. Signed-off-by: Artifizer --- Gts.Tests/Validation/SchemaValidationTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Gts.Tests/Validation/SchemaValidationTests.cs b/Gts.Tests/Validation/SchemaValidationTests.cs index 4cbac28..46ffcd1 100644 --- a/Gts.Tests/Validation/SchemaValidationTests.cs +++ b/Gts.Tests/Validation/SchemaValidationTests.cs @@ -172,6 +172,39 @@ public async Task Instance_id_returns_invalid_schema_id() 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] public async Task Stored_non_schema_under_type_id_returns_not_a_schema() { From 6386e18a2d65b0823c8e67ad04b707acdde7c96d Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 14:56:34 +0300 Subject: [PATCH 09/17] Fix x-gts-ref matching, $ref resolution, schema caching, and id parsing Address the review findings on PR #3: - GtsRefConstraint.Matches: enforce a segment boundary for exact (non-wildcard) x-gts-ref patterns. Plain prefix matching accepted "...w.v1" against "...w.v12"/"...w.v1.5", so a reference to a different registered instance passed validation. Require a full match or a '~' boundary right after the pattern; type patterns (ending in '~') still admit derived identifiers. - GtsSchemaDependencyGraph.Resolve: evaluate the $ref JSON Pointer fragment against the loaded document and preserve $ref sibling keywords (composed via allOf), so fragment references and the siblings that 2019-09/2020-12 apply are no longer dropped. - GtsJsonSchemaEngine: compile against the reachable schema closure instead of the whole registry, so each cached JsonSchema no longer retains an O(store size) snapshot through its registry.Fetch closure. - GtsSchemaDocumentNormalizer: evaluate a oneOf whose branches differ only by x-gts-ref as anyOf. After x-gts-ref is stripped the branches collapse to identical schemas and oneOf rejected every value; exclusivity is enforced separately by GtsRefValidator. Documented why dotnet uses this normalization while gts-go/gts-rust register a real keyword (JsonSchema.Net exposes no public API to add a keyword to a built-in dialect). - GtsIdParser.TryParse: validate the bare canonical id and reject the gts:// URI form, matching the gts-rust/gts-go reference parsers. The scheme is stripped by $id/$ref-specific callers before parsing, which removes the query/parse-id inconsistencies caused by accepting it here. Signed-off-by: Artifizer --- Gts.Store/GtsRefConstraint.cs | 12 ++++++-- Gts.Store/GtsSchemaDependencyGraph.cs | 28 +++++++++++++++---- Gts.Store/Validation/GtsJsonSchemaEngine.cs | 6 +++- .../Validation/GtsSchemaDocumentNormalizer.cs | 20 +++++++++++++ Gts/Parsing/GtsIdParser.cs | 7 ++++- 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/Gts.Store/GtsRefConstraint.cs b/Gts.Store/GtsRefConstraint.cs index 247aeef..2775dc9 100644 --- a/Gts.Store/GtsRefConstraint.cs +++ b/Gts.Store/GtsRefConstraint.cs @@ -20,8 +20,14 @@ internal static bool TryCreate(string operand, string selectedTypeId, out GtsRef internal bool Matches(string value) { if (Pattern == GtsConstants.IdPrefix + "*") return true; - return Pattern.EndsWith('*') - ? value.StartsWith(Pattern[..^1], StringComparison.Ordinal) - : value.StartsWith(Pattern, StringComparison.Ordinal); + 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/GtsSchemaDependencyGraph.cs b/Gts.Store/GtsSchemaDependencyGraph.cs index d72968d..b5df793 100644 --- a/Gts.Store/GtsSchemaDependencyGraph.cs +++ b/Gts.Store/GtsSchemaDependencyGraph.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Gts.Store.Validation; namespace Gts.Store; @@ -92,12 +93,29 @@ private static JsonObject Resolve(JsonObject schema, Func lo 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 (GtsId.TryParse(id, out var parsed) && parsed is not null && stack.Add(id) && load(parsed) is JsonObject target) + // 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) { - var resolved = Resolve(target, load, stack, depth + 1); - stack.Remove(id); - return resolved; + 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(); diff --git a/Gts.Store/Validation/GtsJsonSchemaEngine.cs b/Gts.Store/Validation/GtsJsonSchemaEngine.cs index c5bf0ad..0e6af27 100644 --- a/Gts.Store/Validation/GtsJsonSchemaEngine.cs +++ b/Gts.Store/Validation/GtsJsonSchemaEngine.cs @@ -25,7 +25,11 @@ public EvaluationResults Evaluate(JsonNode? instance, GtsId rootSchemaId, IReadO // 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)); - var schema = GetOrCompile(fingerprint, () => Compile(rootSchemaId, root, schemas)); + // 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()); } diff --git a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs index 45d8dfa..4548f94 100644 --- a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs +++ b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs @@ -75,10 +75,30 @@ private static void StripXGtsRefDeep(JsonNode? node) { if (obj[keyword] is JsonArray branches) { + // 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" }) diff --git a/Gts/Parsing/GtsIdParser.cs b/Gts/Parsing/GtsIdParser.cs index 4a2966e..61c633c 100644 --- a/Gts/Parsing/GtsIdParser.cs +++ b/Gts/Parsing/GtsIdParser.cs @@ -11,7 +11,12 @@ internal static bool TryParse(string? input, int maxLength, out ParsedGtsId? par parsed = null; if (input is null) return false; - var value = GtsConstants.StripUriPrefix(input); + // 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; From e4f0b00d1dd7c1bc44a1ae83a03ac48defd176ff Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sat, 26 Sep 2026 16:08:21 +0300 Subject: [PATCH 10/17] fix(store): treat non-ref combinator branches as neutral in x-gts-ref The instance-level x-gts-ref pass counted a oneOf/anyOf branch as "matching" whenever it produced no x-gts-ref errors. A branch that declares no x-gts-ref (for example the {"type":"null"} arm of a nullable reference) is never inspected structurally by this pass, so it always produced zero errors and was counted as a match. For the common nullable shape oneOf: [{"type":"string","x-gts-ref":"..."}, {"type":"null"}] a valid string reference therefore matched both branches, and oneOf's exactly-one rule rejected a valid value. Only branches that actually declare an x-gts-ref (directly or nested) now participate in the match count; branches without one are treated as neutral, leaving structural oneOf/anyOf selection to the JSON Schema engine. This aligns the .NET implementation with the other GTS implementations and is covered by the new gts-spec nullable-reference conformance case. Signed-off-by: Artifizer --- Gts.Store/GtsRefValidator.cs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/Gts.Store/GtsRefValidator.cs b/Gts.Store/GtsRefValidator.cs index 12807a4..de2f820 100644 --- a/Gts.Store/GtsRefValidator.cs +++ b/Gts.Store/GtsRefValidator.cs @@ -139,15 +139,26 @@ private static void WalkInstance( { if (obj[keyword] is not JsonArray branches) continue; - var branchResults = new List>(); + // 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); - branchResults.Add(branchErrors); + refBranchResults.Add(branchErrors); } - var matching = branchResults.Count(result => result.Count == 0); - if (branchResults.Count > 0 && (matching == 0 || keyword == "oneOf" && matching != 1)) + 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) @@ -204,6 +215,17 @@ private static bool TryResolvePattern(string pattern, string selectedTypeId, out 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 From 2973b8163b26e5a7690e9ab60c8efeaf8c84b789 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 27 Sep 2026 12:16:46 +0300 Subject: [PATCH 11/17] feat(server): honor ?validate and ?gts-ref-validation on POST /type-schemas Batch type-schema registration previously ignored the request query string: every entry was added with validate: false and the gts-ref-validation mode was never parsed. This diverged from POST /entities, where those parameters control per-entry validation. Parse gts-ref-validation up front so a malformed mode is rejected with 422 before any entry is registered, and thread the validate flag and mode through TryAddAsync so each batch entry honors them exactly as the single-entity endpoint does. Signed-off-by: Artifizer --- Gts.Application/GtsHttpApiExtensions.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs index ab3aec3..bb3b476 100644 --- a/Gts.Application/GtsHttpApiExtensions.cs +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -101,6 +101,14 @@ 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); @@ -123,7 +131,7 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi continue; } - var result = await GtsEntityOperations.TryAddAsync(registry, schema, validate: false, HttpExtractOptions).ConfigureAwait(false); + var result = await GtsEntityOperations.TryAddAsync(registry, schema, validate, HttpExtractOptions, refValidationMode).ConfigureAwait(false); if (!result.Ok) allOk = false; results.Add(new { ok = result.Ok, type_id = typeId, error = result.Error }); From 0e00422e771dc7f96ba85dd1c943b5988cf24b3a Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 27 Sep 2026 15:28:36 +0300 Subject: [PATCH 12/17] feat(store): stage validate=true batch registration behind an overlay Add a staging overlay to the in-memory store: staged entities are included in SnapshotForReadAsync (used by validation) but excluded from committed reads (GetAsync / GetByInstanceIdAsync / query) until CommitStagedAsync. Batch POST /type-schemas?validate=true now stages every entry, validates each against the fully-staged set, then commits the survivors and discards the rest, so an invalid entry is never observable and the batch is order-independent (an entry may resolve $ref / inheritance to a later sibling). Add a concurrent staging-isolation test. Signed-off-by: Artifizer --- Gts.Application/GtsEntityOperations.cs | 64 +++++++++++++++++++ Gts.Application/GtsHttpApiExtensions.cs | 85 ++++++++++++++++++++++--- Gts.Store/GtsRegistry.cs | 25 +++++++- Gts.Store/IGtsStore.cs | 28 +++++++- Gts.Store/InMemory/InMemoryGtsStore.cs | 64 +++++++++++++++++++ Gts.Tests/StagingConcurrencyTests.cs | 78 +++++++++++++++++++++++ 6 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 Gts.Tests/StagingConcurrencyTests.cs diff --git a/Gts.Application/GtsEntityOperations.cs b/Gts.Application/GtsEntityOperations.cs index 9d95257..aa48df9 100644 --- a/Gts.Application/GtsEntityOperations.cs +++ b/Gts.Application/GtsEntityOperations.cs @@ -122,4 +122,68 @@ public static async Task TryAddAsync( 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); } + + /// + /// 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) + { + 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 + { + GtsSchemaRefFormatValidator.ValidateRefs(body); + } + catch (Exception ex) + { + return (null, ex.Message); + } + return (entity, null); + } + + /// + /// 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) + { + if (entity.GtsId is null) + return "Unable to detect GTS ID in schema"; + var registered = await registry.SnapshotForReadAsync().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) + .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 bb3b476..34df0f0 100644 --- a/Gts.Application/GtsHttpApiExtensions.cs +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -113,28 +113,95 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi 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) + { + // 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++) + { + 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 }); + } + + // 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), validate each + // against the fully-staged set (so intra-batch references/ancestors resolve regardless of + // order), then commit the entries that passed and discard the ones that failed. + var staged = new List<(int Index, string TypeId, GtsJsonEntity Entity, string Key)>(); + for (var i = 0; i < schemas.Count; i++) { - if (item is not JsonObject schema) + 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; - results.Add(new { ok = false, type_id = (string?)null, error = "GTS Type Schema entry must be a JSON object" }); 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; - results.Add(new { ok = false, type_id = (string?)null, error = identityError }); 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 key = await registry.StageAsync(entity).ConfigureAwait(false); + staged.Add((i, typeId, entity, key)); + } - var result = await GtsEntityOperations.TryAddAsync(registry, schema, validate, HttpExtractOptions, refValidationMode).ConfigureAwait(false); - if (!result.Ok) + // Phase 2: validate each staged entry against the fully-staged set. + var verdicts = new List<(int Index, string TypeId, string Key, string? Error)>(); + foreach (var (index, typeId, entity, key) in staged) + { + var error = await GtsEntityOperations.ValidateStagedSchemaAsync(registry, entity, refValidationMode).ConfigureAwait(false); + verdicts.Add((index, typeId, key, error)); + } + + // Phase 3: publish the entries that passed, discard the ones that failed. + foreach (var (index, typeId, key, error) in verdicts) + { + if (error is null) + { + await registry.CommitStagedAsync(key).ConfigureAwait(false); + results[index] = new { ok = true, type_id = (string?)typeId, error = (string?)null }; + } + else + { + await registry.DiscardStagedAsync(key).ConfigureAwait(false); + results[index] = new { ok = false, type_id = (string?)typeId, error = (string?)error }; allOk = false; - results.Add(new { ok = result.Ok, type_id = typeId, error = result.Error }); + } } return Results.Json(new { ok = allOk, results }); diff --git a/Gts.Store/GtsRegistry.cs b/Gts.Store/GtsRegistry.cs index 7cb2e86..ffe7b76 100644 --- a/Gts.Store/GtsRegistry.cs +++ b/Gts.Store/GtsRegistry.cs @@ -65,6 +65,28 @@ public ValueTask> GetAllAsync() return _store.GetAllAsync(); } + /// + /// Stages an entity WITHOUT publishing it (invisible to public reads until ), + /// returning its staged key. Used by validate=true registration so an entity that has not yet passed + /// validation is never observable, and so a batch can resolve intra-batch references regardless of order. + /// + public ValueTask StageAsync(GtsJsonEntity entity) + { + return _store.StageAsync(entity); + } + + /// Publishes a previously staged entity by its staged key. + public ValueTask CommitStagedAsync(string key) + { + return _store.CommitStagedAsync(key); + } + + /// Discards a staged entity by its staged key; the committed state is untouched. + public ValueTask DiscardStagedAsync(string key) + { + return _store.DiscardStagedAsync(key); + } + /// /// 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. @@ -99,7 +121,8 @@ public async ValueTask QueryAsync( // 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). - var all = await _store.SnapshotForReadAsync().ConfigureAwait(false); + // 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); diff --git a/Gts.Store/IGtsStore.cs b/Gts.Store/IGtsStore.cs index 4b5599f..927a4bc 100644 --- a/Gts.Store/IGtsStore.cs +++ b/Gts.Store/IGtsStore.cs @@ -41,11 +41,35 @@ public interface IGtsStore /// /// Returns a read-only snapshot of all stored entities without deep-cloning their content, - /// for internal read-only consumers (e.g. validation). The returned entities and their + /// for internal read-only consumers (e.g. validation). Includes the staging overlay (see + /// ) so a schema being validated as part of a batch can resolve its + /// not-yet-committed siblings. The returned entities and their /// must not be mutated. Prefer this over on hot paths that only read. /// ValueTask> SnapshotForReadAsync(); - + + /// + /// 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 its registry key. A staged entity is visible to + /// internal validation via (so a batch resolves intra-batch references + /// regardless of order) but invisible to public reads (, , + /// , ) until . + /// Callers MUST eventually commit or discard the staged key. + /// + ValueTask StageAsync(GtsJsonEntity entity); + + /// Publishes a previously staged entity (by its staged key), making it visible to public reads. + ValueTask CommitStagedAsync(string key); + + /// Drops a staged entity (by its staged key). The committed state is untouched. + ValueTask DiscardStagedAsync(string key); + /// Returns the number of stored entities. ValueTask CountAsync(); } diff --git a/Gts.Store/InMemory/InMemoryGtsStore.cs b/Gts.Store/InMemory/InMemoryGtsStore.cs index 9caeab0..35722b6 100644 --- a/Gts.Store/InMemory/InMemoryGtsStore.cs +++ b/Gts.Store/InMemory/InMemoryGtsStore.cs @@ -12,6 +12,10 @@ internal sealed class InMemoryGtsStore : IGtsStore { 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) so a batch resolves intra-batch references regardless of order, but + // invisible to public reads until CommitStagedAsync. Keyed by the resolved instance key. + private readonly Dictionary _staged = new(StringComparer.Ordinal); private readonly object _sync = new(); /// @@ -127,6 +131,29 @@ public ValueTask> GetAllAsync() /// public ValueTask> SnapshotForReadAsync() + { + lock (_sync) + { + // Committed entities overlaid with the staging area (staged wins for the same key), + // so a schema being validated as part of a batch resolves its staged siblings. + var byKey = new Dictionary(_instanceKeys, StringComparer.Ordinal); + foreach (var kv in _staged) + byKey[kv.Key] = kv.Value; + + 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) { @@ -142,6 +169,43 @@ public ValueTask> SnapshotForReadAsync() } } + /// + public ValueTask StageAsync(GtsJsonEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + var key = ResolveKey(entity); + var stored = entity.DeepClone(); + lock (_sync) + { + _staged[key] = stored; + } + + return ValueTask.FromResult(key); + } + + /// + public ValueTask CommitStagedAsync(string key) + { + lock (_sync) + { + if (_staged.Remove(key, out var stored)) + StoreLocked(stored, key); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask DiscardStagedAsync(string key) + { + lock (_sync) + { + _staged.Remove(key); + } + + return ValueTask.CompletedTask; + } + /// public ValueTask CountAsync() { diff --git a/Gts.Tests/StagingConcurrencyTests.cs b/Gts.Tests/StagingConcurrencyTests.cs new file mode 100644 index 0000000..869534e --- /dev/null +++ b/Gts.Tests/StagingConcurrencyTests.cs @@ -0,0 +1,78 @@ +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()); + } + + [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)); + } +} From 2ec5471e10e157c8435aa9f37bb5ea42ab4abea1 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 27 Sep 2026 15:38:48 +0300 Subject: [PATCH 13/17] fix: fix formatting issues Signed-off-by: Artifizer --- Gts.Store/IGtsStore.cs | 4 ++-- Gts.Tests/Extraction/ExtractBasicTests.cs | 22 +++++++++--------- Gts.Tests/Extraction/ExtractEntityTests.cs | 14 ++++++------ Gts.Tests/Extraction/ExtractSchemaTests.cs | 10 ++++----- Gts.Tests/GtsIdSegmentTests.cs | 10 ++++----- Gts.Tests/GtsIdTests.cs | 26 +++++++++++----------- Gts.Tests/Matching/PatternMatchingTests.cs | 2 +- Gts.Tests/Parsing/IdentifierParserTests.cs | 1 + Gts.Tests/Parsing/InstanceParserTests.cs | 2 +- Gts.Tests/Parsing/PatternParserTests.cs | 26 +++++++++++----------- Gts.Tests/Parsing/SegmentParserTests.cs | 10 ++++----- Gts.Tests/Parsing/TypeParserTests.cs | 2 +- Gts.Tests/Parsing/VersionParserTests.cs | 2 +- Gts/GtsIdSegment.cs | 20 ++++++++--------- Gts/Parsing/ParseResult.cs | 2 +- Gts/Parsing/Parsers.cs | 16 ++++++------- Gts/Utils/GuidUtils.cs | 4 ++-- 17 files changed, 87 insertions(+), 86 deletions(-) diff --git a/Gts.Store/IGtsStore.cs b/Gts.Store/IGtsStore.cs index 927a4bc..b708126 100644 --- a/Gts.Store/IGtsStore.cs +++ b/Gts.Store/IGtsStore.cs @@ -27,7 +27,7 @@ public interface IGtsStore /// 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); @@ -35,7 +35,7 @@ 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(); 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/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/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/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). /// From 21af6b603885bf0c4c1d7e4c7156eede99644e02 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 27 Sep 2026 21:12:25 +0300 Subject: [PATCH 14/17] fix(store): make validate=true batch staging leak-, conflict- and dependency-safe The two-phase POST /type-schemas?validate=true flow had three defects: staged entries leaked if an exception occurred before the commit phase, a survivor could be committed even though a staged sibling it depends on was discarded, and staging keyed purely by entity id let concurrent batches (or a duplicated batch entry) clobber each other while commit bypassed the conflict check. - Key the staging overlay by a unique token instead of the entity id, so two entries that resolve to the same id never overwrite each other and a commit/discard only affects the entry it names. - Commit atomically with a compare-and-swap against the committed store, returning GtsSaveOutcome so a conflicting publish is reported rather than silently overwriting existing content. - Track pending tokens and discard the leftovers in a finally block, so a throw anywhere in the batch never leaves unvalidated entries in the read overlay. - Iteratively discard failures and re-validate the survivors against the now-smaller staged set until a round is clean, so nothing is committed with a dangling parent or reference to a discarded sibling. Adds store-level tests for token isolation and compare-and-swap commit. Signed-off-by: Artifizer --- Gts.Application/GtsHttpApiExtensions.cs | 136 +++++++++++++++--------- Gts.Store/GtsRegistry.cs | 17 +-- Gts.Store/IGtsStore.cs | 22 ++-- Gts.Store/InMemory/InMemoryGtsStore.cs | 42 +++++--- Gts.Tests/StagingConcurrencyTests.cs | 59 ++++++++++ 5 files changed, 201 insertions(+), 75 deletions(-) diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs index 34df0f0..41eca86 100644 --- a/Gts.Application/GtsHttpApiExtensions.cs +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -144,65 +144,103 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi // 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), validate each - // against the fully-staged set (so intra-batch references/ancestors resolve regardless of - // order), then commit the entries that passed and discard the ones that failed. - var staged = new List<(int Index, string TypeId, GtsJsonEntity Entity, string Key)>(); - for (var i = 0; i < schemas.Count; i++) + // against the staged set (so intra-batch references/ancestors resolve regardless of order), + // then commit the entries that passed 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 staged = new List<(int Index, string TypeId, GtsJsonEntity Entity, string Token)>(); + var pending = new HashSet(StringComparer.Ordinal); + try { - 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) + for (var i = 0; i < schemas.Count; i++) { - results[i] = new { ok = false, type_id = (string?)typeId, error = prepError }; - allOk = false; - 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).ConfigureAwait(false); + pending.Add(token); + staged.Add((i, typeId, entity, token)); } - // 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)) + + // 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) { - results[i] = new { ok = false, type_id = (string?)typeId, error = "Entity already exists with different content" }; - allOk = false; - continue; - } - var key = await registry.StageAsync(entity).ConfigureAwait(false); - staged.Add((i, typeId, entity, key)); - } + 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).ConfigureAwait(false); + if (error is null) + stillGood.Add((index, typeId, entity, token)); + else + failedThisRound.Add((index, typeId, token, error)); + } - // Phase 2: validate each staged entry against the fully-staged set. - var verdicts = new List<(int Index, string TypeId, string Key, string? Error)>(); - foreach (var (index, typeId, entity, key) in staged) - { - var error = await GtsEntityOperations.ValidateStagedSchemaAsync(registry, entity, refValidationMode).ConfigureAwait(false); - verdicts.Add((index, typeId, key, error)); - } + if (failedThisRound.Count == 0) + break; - // Phase 3: publish the entries that passed, discard the ones that failed. - foreach (var (index, typeId, key, error) in verdicts) - { - if (error is null) - { - await registry.CommitStagedAsync(key).ConfigureAwait(false); - results[index] = new { ok = true, type_id = (string?)typeId, error = (string?)null }; + 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; } - else + + // Phase 3: publish the entries that passed. A commit can still report a conflict if a + // concurrent batch committed the same id with different content in the meantime. + foreach (var (index, typeId, _, token) in survivors) { - await registry.DiscardStagedAsync(key).ConfigureAwait(false); - results[index] = new { ok = false, type_id = (string?)typeId, error = (string?)error }; - allOk = false; + var outcome = await registry.CommitStagedAsync(token).ConfigureAwait(false); + pending.Remove(token); + if (outcome == GtsSaveOutcome.Conflict) + { + results[index] = new { ok = false, type_id = (string?)typeId, error = (string?)"Entity already exists with different content" }; + allOk = false; + } + else + { + results[index] = new { ok = true, type_id = (string?)typeId, error = (string?)null }; + } } } + 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 }); }); diff --git a/Gts.Store/GtsRegistry.cs b/Gts.Store/GtsRegistry.cs index ffe7b76..facefdc 100644 --- a/Gts.Store/GtsRegistry.cs +++ b/Gts.Store/GtsRegistry.cs @@ -67,7 +67,7 @@ public ValueTask> GetAllAsync() /// /// Stages an entity WITHOUT publishing it (invisible to public reads until ), - /// returning its staged key. Used by validate=true registration so an entity that has not yet passed + /// returning a unique staging token. Used by validate=true registration so an entity that has not yet passed /// validation is never observable, and so a batch can resolve intra-batch references regardless of order. /// public ValueTask StageAsync(GtsJsonEntity entity) @@ -75,16 +75,19 @@ public ValueTask StageAsync(GtsJsonEntity entity) return _store.StageAsync(entity); } - /// Publishes a previously staged entity by its staged key. - public ValueTask CommitStagedAsync(string key) + /// + /// 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(key); + return _store.CommitStagedAsync(token); } - /// Discards a staged entity by its staged key; the committed state is untouched. - public ValueTask DiscardStagedAsync(string key) + /// Discards a staged entity by its staging token; the committed state is untouched. + public ValueTask DiscardStagedAsync(string token) { - return _store.DiscardStagedAsync(key); + return _store.DiscardStagedAsync(token); } /// diff --git a/Gts.Store/IGtsStore.cs b/Gts.Store/IGtsStore.cs index b708126..f625083 100644 --- a/Gts.Store/IGtsStore.cs +++ b/Gts.Store/IGtsStore.cs @@ -56,19 +56,29 @@ public interface IGtsStore ValueTask> SnapshotCommittedForReadAsync(); /// - /// Stages an entity WITHOUT publishing it, returning its registry key. A staged entity is visible to + /// Stages an entity WITHOUT publishing it, returning a unique staging token that + /// identifies this staged entry (never its registry key). A staged entity is visible to /// internal validation via (so a batch resolves intra-batch references /// regardless of order) but invisible to public reads (, , /// , ) until . - /// Callers MUST eventually commit or discard the staged key. + /// 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); - /// Publishes a previously staged entity (by its staged key), making it visible to public reads. - ValueTask CommitStagedAsync(string key); + /// + /// 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); - /// Drops a staged entity (by its staged key). The committed state is untouched. - ValueTask DiscardStagedAsync(string key); + /// 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(); diff --git a/Gts.Store/InMemory/InMemoryGtsStore.cs b/Gts.Store/InMemory/InMemoryGtsStore.cs index 35722b6..00f57e4 100644 --- a/Gts.Store/InMemory/InMemoryGtsStore.cs +++ b/Gts.Store/InMemory/InMemoryGtsStore.cs @@ -14,10 +14,14 @@ internal sealed class InMemoryGtsStore : IGtsStore private readonly Dictionary _instanceKeys = new(StringComparer.Ordinal); // Staging overlay: entities registered but not yet committed. Visible to internal validation // (via SnapshotForReadAsync) so a batch resolves intra-batch references regardless of order, but - // invisible to public reads until CommitStagedAsync. Keyed by the resolved instance key. - private readonly Dictionary _staged = new(StringComparer.Ordinal); + // invisible to public reads until CommitStagedAsync. 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. + private readonly Dictionary _staged = new(StringComparer.Ordinal); private readonly object _sync = new(); + private readonly record struct StagedEntry(string Key, GtsJsonEntity Entity); + /// public ValueTask SaveAsync(GtsJsonEntity entity) { @@ -137,8 +141,8 @@ public ValueTask> SnapshotForReadAsync() // Committed entities overlaid with the staging area (staged wins for the same key), // so a schema being validated as part of a batch resolves its staged siblings. var byKey = new Dictionary(_instanceKeys, StringComparer.Ordinal); - foreach (var kv in _staged) - byKey[kv.Key] = kv.Value; + foreach (var entry in _staged.Values) + byKey[entry.Key] = entry.Entity; var seen = new HashSet(ReferenceEqualityComparer.Instance); var list = new List(); @@ -175,32 +179,44 @@ public ValueTask StageAsync(GtsJsonEntity entity) ArgumentNullException.ThrowIfNull(entity); var key = ResolveKey(entity); var stored = entity.DeepClone(); + var token = Guid.NewGuid().ToString("N"); lock (_sync) { - _staged[key] = stored; + _staged[token] = new StagedEntry(key, stored); } - return ValueTask.FromResult(key); + return ValueTask.FromResult(token); } /// - public ValueTask CommitStagedAsync(string key) + public ValueTask CommitStagedAsync(string token) { lock (_sync) { - if (_staged.Remove(key, out var stored)) - StoreLocked(stored, key); - } + if (!_staged.Remove(token, out var entry)) + return ValueTask.FromResult(GtsSaveOutcome.Conflict); - return ValueTask.CompletedTask; + // 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 DiscardStagedAsync(string key) + public ValueTask DiscardStagedAsync(string token) { lock (_sync) { - _staged.Remove(key); + _staged.Remove(token); } return ValueTask.CompletedTask; diff --git a/Gts.Tests/StagingConcurrencyTests.cs b/Gts.Tests/StagingConcurrencyTests.cs index 869534e..316deb1 100644 --- a/Gts.Tests/StagingConcurrencyTests.cs +++ b/Gts.Tests/StagingConcurrencyTests.cs @@ -25,6 +25,65 @@ private static GtsJsonEntity Schema(string typeId) 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()); + } + [Fact] public async Task Staged_entity_is_never_visible_to_concurrent_committed_reads() { From 7ed2c352adc90423038ac889bc079ad4e722fd4d Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 16:49:21 +0300 Subject: [PATCH 15/17] fix(validation): bound schema-pattern match time to prevent ReDoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JsonSchema.Net 9.1.3 compiled `pattern` / `patternProperties` with Regex.InfiniteMatchTimeout, so an untrusted schema carrying a catastrophic backtracking pattern (e.g. (a+)+$) matched against an adversarial instance value could pin a CPU indefinitely (CWE-1333). Setting the process-wide default match timeout does not help — the library explicitly opts out of it. Upgrade JsonSchema.Net to 9.4.0 (and JsonPointer.Net to 7.0.2 to satisfy its dependency), which bounds regex match time internally and raises RegexMatchTimeoutException instead of hanging. Catch that exception on the instance-validation and cast paths and surface it as a validation failure ("regular expression match timed out"), mirroring the runtime match-timeout protection in gts-go and gts-python. The schema-traits and x-gts-ref existence paths already convert evaluation exceptions to failures. Adds a regression test asserting a catastrophic pattern against a 40k-char adversarial instance returns a bounded failure rather than hanging. Signed-off-by: Artifizer --- Gts.Store/Gts.Store.csproj | 4 +- Gts.Store/GtsCastService.cs | 15 ++++- Gts.Store/GtsInstanceValidationService.cs | 39 ++++++++---- .../Validation/InstanceValidationTests.cs | 59 +++++++++++++++++++ 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/Gts.Store/Gts.Store.csproj b/Gts.Store/Gts.Store.csproj index 2b8a8d4..a5080ca 100644 --- a/Gts.Store/Gts.Store.csproj +++ b/Gts.Store/Gts.Store.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/Gts.Store/GtsCastService.cs b/Gts.Store/GtsCastService.cs index d7f3a3f..b22a8d5 100644 --- a/Gts.Store/GtsCastService.cs +++ b/Gts.Store/GtsCastService.cs @@ -1,6 +1,8 @@ using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Gts.Extraction; using Gts.Store.Validation; +using Json.Schema; namespace Gts.Store; @@ -55,7 +57,18 @@ internal async ValueTask CastAsync(string instanceId, Gts var tolerant = (JsonObject)GtsInstanceCast.RemoveGtsConstConstraints(targetDocument.DeepClone())!.AsObject(); schemas[targetSchemaId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(tolerant); - var evaluation = GtsJsonSchemaEvaluator.Evaluate(casted, targetSchemaId, schemas); + 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 }; diff --git a/Gts.Store/GtsInstanceValidationService.cs b/Gts.Store/GtsInstanceValidationService.cs index 7ec9853..5914469 100644 --- a/Gts.Store/GtsInstanceValidationService.cs +++ b/Gts.Store/GtsInstanceValidationService.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Gts.Extraction; using Gts.Store.Validation; @@ -73,17 +74,35 @@ internal async ValueTask ValidateAsync( : loaded; } var effectiveSchema = GtsSchemaDependencyGraph.Resolve(schemaDocument, Load); - 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 + try { - Ok = false, - Id = instanceId, - FailureReason = referenceErrors.Count > 0 ? GtsValidationFailure.GtsRefValidationFailed : GtsValidationFailure.SchemaValidationFailed, - SchemaErrors = referenceErrors.Count > 0 ? referenceErrors : GtsJsonSchemaEvaluator.FlattenErrors(evaluation) - }; + 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 }; diff --git a/Gts.Tests/Validation/InstanceValidationTests.cs b/Gts.Tests/Validation/InstanceValidationTests.cs index fbae9b0..ae5da24 100644 --- a/Gts.Tests/Validation/InstanceValidationTests.cs +++ b/Gts.Tests/Validation/InstanceValidationTests.cs @@ -304,4 +304,63 @@ public async Task Anonymous_invalid_instance_fails() Assert.False(result.Ok); 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"); + } } From 1981c3227b9667329991e8b0c51a57c2fa9a06b0 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 22:22:55 +0300 Subject: [PATCH 16/17] Isolate staging per session and publish batches atomically The validate=true registration overlay was global: one request's staged, not-yet-validated entries were visible to another request's validation, so a batch could resolve a parent or $ref target against another batch's unvalidated entry and commit a dangling reference. Separately, the batch commit loop published survivors one-by-one, so a dependent could be published against a target whose committed content changed after the dependent had been validated. Scope the staging overlay to a per-request staging session (committed-only when no session is active) so a batch resolves only its own siblings, and replace the per-entry commit loop with a store-level all-or-nothing batch compare-and-swap that publishes nothing when any target conflicts. Signed-off-by: Artifizer --- Gts.Application/GtsEntityOperations.cs | 7 +- Gts.Application/GtsHttpApiExtensions.cs | 34 +++++--- Gts.Store/GtsRegistry.cs | 34 +++++--- Gts.Store/GtsSchemaValidationService.cs | 4 +- Gts.Store/IGtsStore.cs | 38 ++++++--- Gts.Store/InMemory/InMemoryGtsStore.cs | 101 +++++++++++++++++++++--- Gts.Tests/StagingConcurrencyTests.cs | 79 ++++++++++++++++++ 7 files changed, 248 insertions(+), 49 deletions(-) diff --git a/Gts.Application/GtsEntityOperations.cs b/Gts.Application/GtsEntityOperations.cs index aa48df9..f625a22 100644 --- a/Gts.Application/GtsEntityOperations.cs +++ b/Gts.Application/GtsEntityOperations.cs @@ -170,15 +170,16 @@ public static (GtsJsonEntity? Entity, string? Error) PrepareSchema(JsonObject bo GtsRegistry registry, GtsJsonEntity entity, GtsRefValidationMode refValidationMode, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? stagingSessionId = null) { if (entity.GtsId is null) return "Unable to detect GTS ID in schema"; - var registered = await registry.SnapshotForReadAsync().ConfigureAwait(false); + 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) + var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode, stagingSessionId) .ConfigureAwait(false); if (!schemaVr.Ok) return schemaVr.Errors is { Count: > 0 } diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs index 41eca86..3448a8f 100644 --- a/Gts.Application/GtsHttpApiExtensions.cs +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -143,11 +143,13 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi } // 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), validate each - // against the staged set (so intra-batch references/ancestors resolve regardless of order), - // then commit the entries that passed 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. + // 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 @@ -181,7 +183,7 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi allOk = false; continue; } - var token = await registry.StageAsync(entity).ConfigureAwait(false); + var token = await registry.StageAsync(entity, stagingSession).ConfigureAwait(false); pending.Add(token); staged.Add((i, typeId, entity, token)); } @@ -197,7 +199,7 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi 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).ConfigureAwait(false); + var error = await GtsEntityOperations.ValidateStagedSchemaAsync(registry, entity, refValidationMode, default, stagingSession).ConfigureAwait(false); if (error is null) stillGood.Add((index, typeId, entity, token)); else @@ -217,19 +219,25 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi survivors = stillGood; } - // Phase 3: publish the entries that passed. A commit can still report a conflict if a - // concurrent batch committed the same id with different content in the meantime. - foreach (var (index, typeId, _, token) in survivors) + // 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++) { - var outcome = await registry.CommitStagedAsync(token).ConfigureAwait(false); - pending.Remove(token); - if (outcome == GtsSaveOutcome.Conflict) + 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 }; } } diff --git a/Gts.Store/GtsRegistry.cs b/Gts.Store/GtsRegistry.cs index facefdc..d5e6d49 100644 --- a/Gts.Store/GtsRegistry.cs +++ b/Gts.Store/GtsRegistry.cs @@ -66,13 +66,14 @@ public ValueTask> GetAllAsync() } /// - /// Stages an entity WITHOUT publishing it (invisible to public reads until ), - /// returning a unique staging token. Used by validate=true registration so an entity that has not yet passed - /// validation is never observable, and so a batch can resolve intra-batch references regardless of order. + /// 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) + public ValueTask StageAsync(GtsJsonEntity entity, string? stagingSessionId = null) { - return _store.StageAsync(entity); + return _store.StageAsync(entity, stagingSessionId); } /// @@ -84,6 +85,16 @@ 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) { @@ -92,11 +103,13 @@ public ValueTask DiscardStagedAsync(string 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. + /// 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() + public ValueTask> SnapshotForReadAsync(string? stagingSessionId = null) { - return _store.SnapshotForReadAsync(); + return _store.SnapshotForReadAsync(stagingSessionId); } /// Returns the number of entities in the registry. @@ -234,8 +247,9 @@ public ValueTask ValidateSchemaAsync( GtsId schemaTypeId, JsonObject schemaDocument, CancellationToken cancellationToken = default, - GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default) => - _schemaValidation.ValidateAsync(schemaTypeId, schemaDocument, cancellationToken, refValidationMode); + GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default, + string? stagingSessionId = null) => + _schemaValidation.ValidateAsync(schemaTypeId, schemaDocument, cancellationToken, refValidationMode, stagingSessionId); /// /// Validates a stored schema by type id (see ). diff --git a/Gts.Store/GtsSchemaValidationService.cs b/Gts.Store/GtsSchemaValidationService.cs index 7be82fb..0da7a1f 100644 --- a/Gts.Store/GtsSchemaValidationService.cs +++ b/Gts.Store/GtsSchemaValidationService.cs @@ -18,7 +18,7 @@ internal async ValueTask ValidateStoredAsync(string s return await ValidateAsync(parsed, entity.Content, cancellationToken, refValidationMode).ConfigureAwait(false); } - internal async ValueTask ValidateAsync(GtsId schemaId, JsonObject document, CancellationToken cancellationToken, GtsRefValidationMode refValidationMode) + internal async ValueTask ValidateAsync(GtsId schemaId, JsonObject document, CancellationToken cancellationToken, GtsRefValidationMode refValidationMode, string? stagingSessionId = null) { ArgumentNullException.ThrowIfNull(schemaId); ArgumentNullException.ThrowIfNull(document); @@ -55,7 +55,7 @@ internal async ValueTask ValidateAsync(GtsId schemaId return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.InvalidRefFormat, Errors = new[] { exception.Message } }; } - var entities = await store.SnapshotForReadAsync().ConfigureAwait(false); + var entities = await store.SnapshotForReadAsync(stagingSessionId).ConfigureAwait(false); var schemaById = new Dictionary(StringComparer.Ordinal); foreach (var candidate in entities) { diff --git a/Gts.Store/IGtsStore.cs b/Gts.Store/IGtsStore.cs index f625083..ca3b054 100644 --- a/Gts.Store/IGtsStore.cs +++ b/Gts.Store/IGtsStore.cs @@ -41,12 +41,15 @@ public interface IGtsStore /// /// Returns a read-only snapshot of all stored entities without deep-cloning their content, - /// for internal read-only consumers (e.g. validation). Includes the staging overlay (see - /// ) so a schema being validated as part of a batch can resolve its - /// not-yet-committed siblings. The returned entities and their + /// 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(); + ValueTask> SnapshotForReadAsync(string? stagingSessionId = null); /// /// Like but committed-only: the staging overlay is never @@ -57,15 +60,18 @@ public interface IGtsStore /// /// Stages an entity WITHOUT publishing it, returning a unique staging token that - /// identifies this staged entry (never its registry key). A staged entity is visible to - /// internal validation via (so a batch resolves intra-batch references - /// regardless of order) but invisible to public reads (, , - /// , ) until . + /// 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); + ValueTask StageAsync(GtsJsonEntity entity, string? stagingSessionId = null); /// /// Publishes a previously staged entity by its staging , making it visible to @@ -77,6 +83,20 @@ public interface IGtsStore /// 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); diff --git a/Gts.Store/InMemory/InMemoryGtsStore.cs b/Gts.Store/InMemory/InMemoryGtsStore.cs index 00f57e4..a5de363 100644 --- a/Gts.Store/InMemory/InMemoryGtsStore.cs +++ b/Gts.Store/InMemory/InMemoryGtsStore.cs @@ -13,14 +13,16 @@ internal sealed class InMemoryGtsStore : IGtsStore 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) so a batch resolves intra-batch references regardless of order, but - // invisible to public reads until CommitStagedAsync. 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. + // (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); + private readonly record struct StagedEntry(string Key, GtsJsonEntity Entity, string Session); /// public ValueTask SaveAsync(GtsJsonEntity entity) @@ -134,15 +136,23 @@ public ValueTask> GetAllAsync() } /// - public ValueTask> SnapshotForReadAsync() + public ValueTask> SnapshotForReadAsync(string? stagingSessionId = null) { lock (_sync) { - // Committed entities overlaid with the staging area (staged wins for the same key), - // so a schema being validated as part of a batch resolves its staged siblings. + // 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); - foreach (var entry in _staged.Values) - byKey[entry.Key] = entry.Entity; + 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(); @@ -174,15 +184,17 @@ public ValueTask> SnapshotCommittedForReadAsync() } /// - public ValueTask StageAsync(GtsJsonEntity entity) + 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); + _staged[token] = new StagedEntry(key, stored, session); } return ValueTask.FromResult(token); @@ -211,6 +223,71 @@ public ValueTask CommitStagedAsync(string token) } } + /// + 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) { diff --git a/Gts.Tests/StagingConcurrencyTests.cs b/Gts.Tests/StagingConcurrencyTests.cs index 316deb1..57b8410 100644 --- a/Gts.Tests/StagingConcurrencyTests.cs +++ b/Gts.Tests/StagingConcurrencyTests.cs @@ -84,6 +84,85 @@ public async Task Discarding_one_token_leaves_another_staged_entry_for_the_same_ 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() { From f80b2c88a720416f4539b44b8ea1af1111e28007 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Mon, 28 Sep 2026 23:11:45 +0300 Subject: [PATCH 17/17] chore: update to gts-spec v0.14.3 Signed-off-by: Artifizer --- .gts-spec-version | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gts-spec-version b/.gts-spec-version index 818cabd..6cf9fa5 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.14.3 +v0.14.4 diff --git a/README.md b/README.md index f6782fb..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.3` (pinned in [`.gts-spec-version`](.gts-spec-version)) +Supported GTS spec version: `v0.14.4` (pinned in [`.gts-spec-version`](.gts-spec-version)) ## Roadmap