Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gts-spec-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.14.4
v0.14.5
9 changes: 4 additions & 5 deletions Gts.Application/GtsHttpApiHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ internal static int EnumConstraintChange(JsonObject oldSchema, JsonObject newSch
return 0;
}

internal static string NormalizeDialect(string? dialect)
internal static string NormalizeDialect(string? declared)
{
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";
return GtsTypeSchema.TryNormalizeSupportedDialect(declared, out var dialect, out _)
? dialect
: "unsupported:" + (declared ?? "<missing>");
}

internal static bool ContainsSchemaKeyword(JsonNode? node, string keyword) => node switch
Expand Down
16 changes: 13 additions & 3 deletions Gts.Store/GtsInstanceValidationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ internal async ValueTask<GtsInstanceValidationResult> ValidateAsync(
{
// 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).
// than propagating a hang/exception. The bounded regex preflight
// mirrors the "regular expression match timed out" outcome the
// sibling runtimes surface (gts-go/gts-python).
return new GtsInstanceValidationResult
{
Ok = false,
Expand All @@ -103,6 +103,16 @@ internal async ValueTask<GtsInstanceValidationResult> ValidateAsync(
SchemaErrors = new[] { "regular expression match timed out" }
};
}
catch (RegexParseException exception)
{
return new GtsInstanceValidationResult
{
Ok = false,
Id = instanceId,
FailureReason = GtsValidationFailure.SchemaValidationFailed,
SchemaErrors = new[] { $"invalid regular expression: {exception.Message}" }
};
}
}

private static GtsInstanceValidationResult Failure(string? id, GtsValidationFailure? reason) => new() { Ok = false, Id = id, FailureReason = reason };
Expand Down
16 changes: 11 additions & 5 deletions Gts.Store/GtsSchemaDependencyGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ internal static class GtsSchemaDependencyGraph
{
internal static string Dialect(JsonObject schema)
{
var value = schema["$schema"]?.GetValue<string>() ?? "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('#');
var document = !schema.ContainsKey("$schema") && schema.ContainsKey("$$schema")
? GtsSchemaDocumentNormalizer.CanonicalizeKeywords(schema)
: schema;
// An unsupported or missing dialect is a validation concern, not an internal fault: the
// callers (ValidateDialects, HasMixedDialectReferences) compare dialects to detect a
// cross-dialect graph and report failures via their result/error channels. Returning a
// sentinel that can never equal a supported dialect keeps an unsupported referenced schema
// a reported mismatch instead of an exception that escapes the validator as an internal error.
return GtsTypeSchema.TryGetSupportedDialect(document, out var dialect, out _)
? dialect
: "unsupported:" + (document["$schema"]?.ToJsonString() ?? "<missing>");
}

internal static JsonObject Resolve(JsonObject schema, Func<GtsId, JsonObject?> load) => Resolve(schema, load, new HashSet<string>(), 0);
Expand Down
38 changes: 2 additions & 36 deletions Gts.Store/GtsSchemaKeywordValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using Gts.Store.Validation;

namespace Gts.Store;

Expand All @@ -12,7 +13,7 @@ public static class GtsSchemaKeywordValidator
public static IReadOnlyList<string> Validate(JsonObject schema)
{
var errors = new List<string>();
ValidateNode(schema, true, errors);
GtsSchemaWalker.Visit(schema, (node, root) => ValidateNode(node, root, 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 _))
Expand All @@ -38,41 +39,6 @@ private static void ValidateNode(JsonObject schema, bool root, List<string> erro
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<string> errors)
{
if (node is not JsonObject map)
return;
foreach (var child in map.Select(property => property.Value).OfType<JsonObject>())
ValidateNode(child, false, errors);
}

private static void RecurseArray(JsonNode? node, List<string> errors)
{
if (node is not JsonArray array)
return;
foreach (var child in array.OfType<JsonObject>())
ValidateNode(child, false, errors);
}

private static void Recurse(JsonNode? node, List<string> errors)
{
if (node is JsonObject child)
ValidateNode(child, false, errors);
else
RecurseArray(node, errors);
}

private static bool TryBoolean(JsonNode? node, out bool value)
Expand Down
12 changes: 4 additions & 8 deletions Gts.Store/GtsSchemaTraitsValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,11 @@ private static void ValidateConstValues(JsonNode? schema, JsonNode? values, stri
}
}

private static string NormalizeDialect(string? dialect)
private static string NormalizeDialect(string? declared)
{
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";
return GtsTypeSchema.TryNormalizeSupportedDialect(declared, out var dialect, out _)
? dialect
: "unsupported:" + (declared ?? "<missing>");
}

private static bool ContainsGtsRef(JsonNode? node) => node switch
Expand Down
9 changes: 9 additions & 0 deletions Gts.Store/GtsSchemaValidationService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json.Nodes;
using Gts.Extraction;
using Gts.Store.Validation;
using Json.Schema;

namespace Gts.Store;

Expand Down Expand Up @@ -62,6 +63,14 @@ internal async ValueTask<GtsSchemaValidationResult> ValidateAsync(GtsId schemaId
if (candidate.IsSchema && candidate.GtsId is not null)
schemaById[candidate.GtsId.Id] = candidate;
}
try
{
GtsJsonSchemaEvaluator.ValidateSchema(GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(document));
}
catch (Exception exception) when (exception is JsonSchemaException or ArgumentException)
{
return new GtsSchemaValidationResult { Ok = false, SchemaId = schemaId.Id, FailureReason = GtsValidationFailure.InvalidJsonSchema, Errors = new[] { exception.Message } };
}

JsonObject? Load(GtsId id)
{
Expand Down
13 changes: 13 additions & 0 deletions Gts.Store/GtsTypeSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ public static bool TryGetSupportedDialect(JsonObject schema, out string dialect,
return false;
}

return TryNormalizeSupportedDialect(declared, out dialect, out error);
}

public static bool TryNormalizeSupportedDialect(string? declared, out string dialect, out string? error)
{
dialect = "";
error = null;
if (string.IsNullOrWhiteSpace(declared))
{
error = "$schema must declare a supported JSON Schema dialect";
return false;
}

var normalized = declared.Replace("https://", "http://", StringComparison.Ordinal).TrimEnd('#');
switch (normalized)
{
Expand Down
4 changes: 2 additions & 2 deletions Gts.Store/Validation/GtsFormatRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ private static bool IsRegex(string value)
{
try
{
_ = new Regex(value, RegexOptions.ECMAScript);
_ = new Regex(value, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250));
return true;
}
catch
catch (ArgumentException)
{
return false;
}
Expand Down
47 changes: 38 additions & 9 deletions Gts.Store/Validation/GtsJsonSchemaEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ internal sealed class GtsJsonSchemaEngine : IGtsJsonSchemaEngine
{
internal static GtsJsonSchemaEngine Default { get; } = new();

private static readonly Dialect Draft07 = GtsRegexDialect.WithBoundedPatterns(Dialect.Draft07);
private static readonly Dialect Draft201909 = GtsRegexDialect.WithBoundedPatterns(Dialect.Draft201909);
private static readonly Dialect Draft202012 = GtsRegexDialect.WithBoundedPatterns(Dialect.Draft202012);
private readonly ConcurrentDictionary<string, JsonSchema> _compiled = new(StringComparer.Ordinal);
private readonly FormatRegistry _formats = GtsFormatRegistry.Create();

Expand Down Expand Up @@ -37,10 +40,26 @@ public EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDoc
{
var normalized = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(schemaDocument);
var fingerprint = Fingerprint("inline", new[] { normalized });
var schema = GetOrCompile(fingerprint, () => JsonSchema.FromText(normalized.ToJsonString(), BuildOptions(normalized, new SchemaRegistry())));
var schema = GetOrCompile(fingerprint, () => JsonSchema.FromText(WithoutDialectDeclaration(normalized).ToJsonString(), BuildOptions(normalized, new SchemaRegistry())));
return schema.Evaluate(GtsJson.ToElement(instance), EvaluationOptions());
}

public void ValidateSchema(JsonObject normalizedSchemaDocument)
{
if (!GtsTypeSchema.TryGetSupportedDialect(normalizedSchemaDocument, out var dialect, out var error))
throw new JsonSchemaException(error!);
var metaSchema = dialect switch
{
"draft-07" => MetaSchemas.Draft7,
"2019-09" => MetaSchemas.Draft201909,
"2020-12" => MetaSchemas.Draft202012,
_ => throw new JsonSchemaException($"Unsupported JSON Schema dialect: {dialect}")
};
var result = metaSchema.Evaluate(GtsJson.ToElement(normalizedSchemaDocument), EvaluationOptions());
if (!result.IsValid)
throw new JsonSchemaException("JSON Schema validation failed: " + string.Join("; ", FlattenErrors(result)));
}

public IReadOnlyList<string> FlattenErrors(EvaluationResults results)
{
var errors = new List<string>();
Expand All @@ -63,9 +82,9 @@ private JsonSchema Compile(GtsId rootId, JsonObject root, IReadOnlyDictionary<Gt
{
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(WithoutDialectDeclaration(document).ToJsonString(), options, uri);
};
return JsonSchema.FromText(root.ToJsonString(), options, GtsSchemaResolutionUris.ToSyntheticUri(rootId.Id));
return JsonSchema.FromText(WithoutDialectDeclaration(root).ToJsonString(), options, GtsSchemaResolutionUris.ToSyntheticUri(rootId.Id));
}

private static BuildOptions BuildOptions(JsonObject schema, SchemaRegistry registry) => new()
Expand All @@ -83,14 +102,24 @@ private JsonSchema Compile(GtsId rootId, JsonObject root, IReadOnlyDictionary<Gt
FormatRegistry = _formats
};

private static JsonObject WithoutDialectDeclaration(JsonObject schema)
{
var result = (JsonObject)schema.DeepClone();
GtsSchemaDocumentNormalizer.RemoveDialectDeclarations(result);
return result;
}

private static Dialect DialectFor(JsonObject schema)
{
var uri = schema["$schema"] is JsonValue value && value.TryGetValue<string>(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;
if (!GtsTypeSchema.TryGetSupportedDialect(schema, out var dialect, out var error))
throw new JsonSchemaException(error!);
return dialect switch
{
"draft-07" => Draft07,
"2019-09" => Draft201909,
"2020-12" => Draft202012,
_ => throw new JsonSchemaException($"Unsupported JSON Schema dialect: {dialect}")
};
}

private static string Fingerprint(string root, IEnumerable<JsonObject> schemas)
Expand Down
3 changes: 3 additions & 0 deletions Gts.Store/Validation/GtsJsonSchemaEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@ internal static EvaluationResults Evaluate(
internal static EvaluationResults EvaluateInline(JsonNode? instance, JsonObject schemaDocument) =>
Engine.EvaluateInline(instance, schemaDocument);

internal static void ValidateSchema(JsonObject normalizedSchemaDocument) =>
Engine.ValidateSchema(normalizedSchemaDocument);

internal static IReadOnlyList<string> FlattenErrors(EvaluationResults results) => Engine.FlattenErrors(results);
}
49 changes: 49 additions & 0 deletions Gts.Store/Validation/GtsRegexKeywords.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Json.Schema;
using Json.Schema.Keywords;

namespace Gts.Store.Validation;

internal sealed class GtsPatternKeyword : PatternKeyword
{
internal static GtsPatternKeyword Handler { get; } = new();

private GtsPatternKeyword() { }

public override object? ValidateKeywordValue(JsonElement value)
{
if (value.ValueKind is not JsonValueKind.String)
throw new JsonSchemaException($"'{Name}' value must be a string, found {value.ValueKind}");

return new Regex(value.GetString()!, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250));
}
}

internal sealed class GtsPatternPropertiesKeyword : PatternPropertiesKeyword
{
internal static GtsPatternPropertiesKeyword Handler { get; } = new();

private GtsPatternPropertiesKeyword() { }

public override object? ValidateKeywordValue(JsonElement value)
{
if (value.ValueKind is not JsonValueKind.Object)
throw new JsonSchemaException($"'{Name}' value must be an object, found {value.ValueKind}");

var regexes = new Dictionary<string, Regex>();
foreach (var property in value.EnumerateObject())
{
if (property.Value.ValueKind is not (JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False))
throw new JsonSchemaException("Values must be valid schemas");
regexes.Add(property.Name, new Regex(property.Name, RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(250)));
}
return regexes;
}
}

internal static class GtsRegexDialect
{
internal static Dialect WithBoundedPatterns(Dialect dialect) => dialect
.With([GtsPatternKeyword.Handler, GtsPatternPropertiesKeyword.Handler]);
}
14 changes: 6 additions & 8 deletions Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ internal static JsonObject CanonicalizeKeywords(JsonObject root)
return clone;
}

internal static void RemoveDialectDeclarations(JsonObject root) =>
GtsSchemaWalker.Visit(root, static (schema, _) => schema.Remove("$schema"), includeTraitsSchema: true);

internal static JsonObject ForJsonSchemaEvaluation(JsonObject root)
{
var clone = (JsonObject)root.DeepClone();
Expand Down Expand Up @@ -81,14 +84,9 @@ private static void StripXGtsRefDeep(JsonNode? node)
// 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.
// NOTE: x-gts-ref is still enforced by GtsRefValidator rather than by the
// JsonSchema.Net dialect. This localized rewrite keeps oneOf branch behavior
// consistent until x-gts-ref is migrated to a first-class keyword handler.
var hadRef = branches.OfType<JsonObject>().Any(branch => branch.ContainsKey(GtsSchemaKeywords.Ref));
foreach (var child in branches)
StripXGtsRefDeep(child);
Expand Down
Loading