diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e168b55 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".gts-spec"] + path = .gts-spec + url = https://github.com/GlobalTypeSystem/gts-spec.git diff --git a/.gts-spec b/.gts-spec new file mode 160000 index 0000000..e088287 --- /dev/null +++ b/.gts-spec @@ -0,0 +1 @@ +Subproject commit e0882879577e7427f759677e9cf2eac7031d978c diff --git a/Gts.Application/Gts.Application.csproj b/Gts.Application/Gts.Application.csproj new file mode 100644 index 0000000..eaafb9e --- /dev/null +++ b/Gts.Application/Gts.Application.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + Gts.Application + + + + + + + + diff --git a/Gts.Application/GtsEntityOperations.cs b/Gts.Application/GtsEntityOperations.cs new file mode 100644 index 0000000..bc04fdb --- /dev/null +++ b/Gts.Application/GtsEntityOperations.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +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 static async Task TryAddAsync( + GtsRegistry registry, + JsonObject body, + bool validate, + GtsExtractOptions? extractOptions = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var opt = extractOptions ?? GtsExtractOptions.Default; + var entity = GtsJsonEntity.ExtractEntity(body, opt); + var extract = GtsJsonEntity.ExtractId(body, opt); + + if (!entity.IsSchema) + { + if (string.IsNullOrEmpty(entity.SelectedEntityField)) + return new AddResult(false, "", null, false, "Instance must have an id field"); + } + else + { + if (entity.GtsId is null) + return new AddResult(false, "", null, true, "Unable to detect GTS ID in schema"); + } + + if (validate && entity.IsSchema) + { + var rawId = body.TryGetPropertyValue("$id", out var idn) ? idn?.GetValue() : null; + if (!string.IsNullOrEmpty(rawId) && rawId.StartsWith("gts.", StringComparison.Ordinal) && + !rawId.StartsWith("gts://", StringComparison.Ordinal)) + return new AddResult(false, "", null, true, "Schema $id must use gts:// URI format, not plain gts. prefix"); + } + + try + { + GtsSchemaRefFormatValidator.ValidateRefs(body); + } + catch (Exception ex) + { + return new AddResult(false, "", null, entity.IsSchema, ex.Message); + } + + if (entity.IsSchema && entity.GtsId is not null) + { + var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken) + .ConfigureAwait(false); + if (!schemaVr.Ok) + { + var msg = schemaVr.Errors is { Count: > 0 } + ? string.Join("; ", schemaVr.Errors) + : (schemaVr.FailureReason ?? "Schema validation failed"); + return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, msg); + } + + await registry.SaveAsync(entity).ConfigureAwait(false); + + var idOut = entity.GtsId.Id; + return new AddResult(true, idOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, true, null); + } + + await registry.SaveAsync(entity).ConfigureAwait(false); + + if (validate && !entity.IsSchema && entity.GtsId is not null) + { + 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"); + } + + var idOut = entity.GtsId?.Id ?? extract.Id ?? ""; + return new AddResult(true, idOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, entity.IsSchema, null); + } +} diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs new file mode 100644 index 0000000..4222744 --- /dev/null +++ b/Gts.Application/GtsHttpApiExtensions.cs @@ -0,0 +1,422 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Gts; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Application; + +/// Maps the GTS HTTP API onto (shared by Gts.Server and CLI server). +public static class GtsHttpApiExtensions +{ + public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry registry) + { + app.MapGet("/entities", async (int? limit) => + { + var l = limit is >= 1 and <= 1000 ? limit.Value : 100; + var all = await registry.GetAllAsync().ConfigureAwait(false); + var list = all.Where(e => e.GtsId is not null).Take(l) + .Select(e => new { id = e.GtsId!.Id, schema_id = string.IsNullOrEmpty(e.SchemaId) ? null : e.SchemaId, is_schema = e.IsSchema }) + .ToList(); + return Results.Json(new { entities = list, count = list.Count, total = all.Count(e => e.GtsId is not null) }); + }); + + app.MapGet("/entities/{*gtsId}", async (string gtsId) => + { + var id = Uri.UnescapeDataString(gtsId.Trim()); + GtsJsonEntity? e = await registry.GetByInstanceIdAsync(id).ConfigureAwait(false); + if (e is null && GtsId.TryParse(id, out var gid) && gid is not null) + e = await registry.GetAsync(gid).ConfigureAwait(false); + if (e is null) + return Results.Json(new { ok = false, error = $"Entity '{id}' not found" }); + + return Results.Json(new + { + ok = true, + id = e.GtsId?.Id ?? id, + schema_id = string.IsNullOrEmpty(e.SchemaId) ? null : e.SchemaId, + is_schema = e.IsSchema, + content = JsonNode.Parse(e.Content.ToJsonString()) + }); + }); + + app.MapPost("/entities", async (HttpRequest req) => + { + var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase); + 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 }, + statusCode: StatusCodes.Status422UnprocessableEntity); + + GtsJsonKeyNormalizer.Apply(body); + var result = await GtsEntityOperations.TryAddAsync(registry, body, validate).ConfigureAwait(false); + if (!result.Ok) + return Results.Json(new { ok = false, error = result.Error, is_schema = result.IsSchema }, + statusCode: StatusCodes.Status422UnprocessableEntity); + + return Results.Json(new { ok = true, id = result.Id, schema_id = result.SchemaId, is_schema = result.IsSchema }); + }); + + app.MapPost("/entities/bulk", async (HttpRequest req) => + { + var arr = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false) as JsonArray; + if (arr is null) + return Results.Json(new { ok = false, results = Array.Empty() }); + + var results = new List(); + var allOk = true; + foreach (var item in arr) + { + if (item is not JsonObject body) + continue; + GtsJsonKeyNormalizer.Apply(body); + var result = await GtsEntityOperations.TryAddAsync(registry, body, validate: false).ConfigureAwait(false); + if (!result.Ok) + allOk = false; + results.Add(new { ok = result.Ok, id = result.Id, schema_id = result.SchemaId, is_schema = result.IsSchema, error = result.Error }); + } + + return Results.Json(new { ok = allOk, results }); + }); + + app.MapPost("/schemas", async (HttpRequest req) => + { + var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); + if (node is not JsonObject body || !body.TryGetPropertyValue("type_id", out var tid) || + tid is not JsonValue tv || !tv.TryGetValue(out var typeId)) + return Results.Json(new { ok = false, error = "type_id required" }, statusCode: 422); + + if (!body.TryGetPropertyValue("schema", out var schemaNode) || schemaNode is not JsonObject schemaObj) + return Results.Json(new { ok = false, error = "schema required" }, statusCode: 422); + + var wrapped = (JsonObject)schemaObj.DeepClone()!; + var gtsUri = "gts://" + typeId.Trim(); + wrapped["$id"] = gtsUri; + if (!wrapped.TryGetPropertyValue("$schema", out _)) + wrapped["$schema"] = "http://json-schema.org/draft-07/schema#"; + + GtsJsonKeyNormalizer.Apply(wrapped); + var result = await GtsEntityOperations.TryAddAsync(registry, wrapped, validate: false).ConfigureAwait(false); + if (!result.Ok) + return Results.Json(new { ok = false, error = result.Error }, statusCode: 422); + + return Results.Json(new { ok = true, id = typeId }); + }); + + app.MapGet("/validate-id", (string gts_id) => + { + var id = gts_id ?? ""; + var isWildcard = id.Contains('*', StringComparison.Ordinal); + if (isWildcard) + { + var pr = GtsId.TryParsePattern(id, out var pat); + if (pr && pat is not null) + return Results.Json(new { id, valid = true, error = "", is_wildcard = true }); + return Results.Json(new { id, valid = false, error = "Invalid wildcard pattern", is_wildcard = true }); + } + + if (GtsId.TryParse(id, out var _)) + return Results.Json(new { id, valid = true, error = "", is_wildcard = false }); + + return Results.Json(new { id, valid = false, error = "Invalid GTS id", is_wildcard = false }); + }); + + app.MapPost("/extract-id", async (HttpRequest req) => + { + var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); + if (node is not JsonObject body) + return Results.Json(new object()); + GtsJsonKeyNormalizer.Apply(body); + var r = GtsJsonEntity.ExtractId(body); + return Results.Json(new + { + id = r.Id, + schema_id = r.SchemaId, + selected_entity_field = r.SelectedEntityField, + selected_schema_id_field = r.SelectedSchemaIdField, + is_schema = r.IsSchema + }); + }); + + app.MapGet("/parse-id", (string gts_id) => + { + var id = gts_id ?? ""; + var isWildcard = id.Contains('*', StringComparison.Ordinal); + if (isWildcard) + { + if (GtsId.TryParsePattern(id, out var pat) && pat is not null) + { + var wildcardIsSchema = id.EndsWith(".*", StringComparison.Ordinal) || + id.EndsWith("~*", StringComparison.Ordinal); + return Results.Json(new + { + id, + ok = true, + segments = pat.Segments.Select(GtsSegmentDto.FromSegment).ToList(), + error = "", + is_wildcard = true, + is_schema = wildcardIsSchema + }); + } + + return Results.Json(new + { + id, + ok = false, + segments = Array.Empty(), + error = "Invalid pattern", + is_wildcard = true, + is_schema = false + }); + } + + if (!GtsId.TryParse(id, out var gid) || gid is null) + return Results.Json(new + { + id, + ok = false, + segments = Array.Empty(), + error = "Parse error", + is_wildcard = false, + is_schema = false + }); + + return Results.Json(new + { + id, + ok = true, + segments = gid.Segments.Select(GtsSegmentDto.FromSegment).ToList(), + error = "", + is_wildcard = false, + is_schema = gid.IsType + }); + }); + + 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 }); + } + }); + + app.MapGet("/uuid", (string gts_id) => + { + if (!GtsId.TryParse(gts_id, out var id) || id is null) + return Results.Json(new { id = gts_id, uuid = "" }); + return Results.Json(new { id = id.Id, uuid = id.ToGuid().ToString() }); + }); + + app.MapPost("/validate-instance", async (HttpRequest req) => + { + var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); + if (node is not JsonObject body) + return Results.Json(new { id = "", ok = false, error = "instance_id required" }); + if (!body.TryGetPropertyValue("instance_id", out var iid) || iid is not JsonValue jv || + !jv.TryGetValue(out var instanceId)) + return Results.Json(new { id = "", ok = false, error = "instance_id required" }); + + var r = await registry.ValidateInstanceAsync(instanceId).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" }); + }); + + app.MapPost("/validate-schema", async (HttpRequest req) => + { + var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); + if (node is not JsonObject body) + return Results.Json(new { id = "", ok = false, error = "schema_id required" }); + if (!body.TryGetPropertyValue("schema_id", out var sid) || sid is not JsonValue sv || !sv.TryGetValue(out var schemaId)) + return Results.Json(new { id = "", ok = false, error = "schema_id required" }); + + if (!GtsId.TryParse(schemaId, out var gid) || gid is null || !gid.IsType) + return Results.Json(new { id = schemaId, ok = false, error = "Invalid schema id" }); + + var vr = await registry.ValidateSchemaAsync(gid).ConfigureAwait(false); + if (!vr.Ok) + { + var detail = vr.Errors is { Count: > 0 } + ? string.Join("; ", vr.Errors) + : (vr.FailureReason ?? "validation failed"); + return Results.Json(new { id = schemaId, ok = false, error = detail }); + } + + return Results.Json(new { id = schemaId, ok = true }); + }); + + app.MapPost("/validate-entity", async (HttpRequest req) => + { + var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false); + if (node is not JsonObject body) + return Results.Json(new { id = "", ok = false, error = "entity_id required" }); + if (!body.TryGetPropertyValue("entity_id", out var eid) || eid is not JsonValue ev || !ev.TryGetValue(out var entityId)) + return Results.Json(new { id = "", ok = false, error = "entity_id required" }); + + if (entityId.EndsWith("~", StringComparison.Ordinal)) + return await ValidateSchemaBody(entityId).ConfigureAwait(false); + + var r = await registry.ValidateInstanceAsync(entityId).ConfigureAwait(false); + return r.Ok + ? Results.Json(new { id = entityId, ok = true }) + : Results.Json(new { id = entityId, ok = false, error = r.FailureReason ?? "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, error = "Invalid id" }); + var vr = await registry.ValidateSchemaAsync(gid).ConfigureAwait(false); + if (!vr.Ok) + { + var detail = vr.Errors is { Count: > 0 } + ? string.Join("; ", vr.Errors) + : (vr.FailureReason ?? "validation failed"); + return Results.Json(new { id = sid, ok = false, error = detail }); + } + + return Results.Json(new { id = sid, ok = true }); + } + }); + + app.MapGet("/resolve-relationships", async (string gts_id) => + { + var graph = await GtsSchemaGraphBuilder.BuildAsync(registry, gts_id).ConfigureAwait(false); + 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 }); + }); + + 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; + } +} diff --git a/Gts.Application/GtsJsonKeyNormalizer.cs b/Gts.Application/GtsJsonKeyNormalizer.cs new file mode 100644 index 0000000..d2f230c --- /dev/null +++ b/Gts.Application/GtsJsonKeyNormalizer.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Nodes; + +namespace Gts.Application; + +/// Normalizes $$-prefixed JSON keys to $ keys (same as HTTP ingestion). +public static class GtsJsonKeyNormalizer +{ + public static void Apply(JsonObject root) => Walk(root); + + private static void Walk(JsonObject o) + { + foreach (var key in o.Select(kv => kv.Key).ToList()) + { + if (key.StartsWith("$$", StringComparison.Ordinal)) + { + var nk = "$" + key[2..]; + o[nk] = o[key]!.DeepClone(); + o.Remove(key); + } + } + + foreach (var (_, v) in o.ToList()) + { + switch (v) + { + case JsonObject jo: + Walk(jo); + break; + case JsonArray ja: + foreach (var x in ja) + { + if (x is JsonObject j2) + Walk(j2); + } + + break; + } + } + } +} diff --git a/Gts.Application/GtsOpenApiSpec.cs b/Gts.Application/GtsOpenApiSpec.cs new file mode 100644 index 0000000..ae96b22 --- /dev/null +++ b/Gts.Application/GtsOpenApiSpec.cs @@ -0,0 +1,160 @@ +namespace Gts.Application; + +/// OpenAPI 3.0 document aligned with the gts-go server GetOpenAPISpec helper. +public static class GtsOpenApiSpec +{ + public static Dictionary Build(string host, int port) + { + var baseUrl = $"http://{host}:{port}"; + return new Dictionary + { + ["openapi"] = "3.0.0", + ["info"] = new Dictionary + { + ["title"] = "GTS Server", + ["version"] = "0.1.0", + ["description"] = "GTS (Global Type System) HTTP API" + }, + ["servers"] = new object[] + { + new Dictionary { ["url"] = baseUrl, ["description"] = "GTS Server" } + }, + ["paths"] = new Dictionary + { + ["/entities"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Get all entities in the registry", + ["operationId"] = "getEntities", + ["parameters"] = new object[] + { + new Dictionary + { + ["name"] = "limit", + ["in"] = "query", + ["description"] = "Maximum number of entities to return", + ["schema"] = new Dictionary { ["type"] = "integer", ["default"] = 100 } + } + } + }, + ["post"] = new Dictionary + { + ["summary"] = "Register a single entity (object or schema)", + ["operationId"] = "addEntity" + } + }, + ["/validate-id"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Validate a GTS ID format", + ["operationId"] = "validateID", + ["parameters"] = new object[] + { + new Dictionary + { + ["name"] = "gts_id", + ["in"] = "query", + ["description"] = "GTS ID to validate", + ["required"] = true, + ["schema"] = new Dictionary { ["type"] = "string" } + } + } + } + }, + ["/parse-id"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Parse a GTS ID into its components", + ["operationId"] = "parseID", + ["parameters"] = new object[] + { + new Dictionary + { + ["name"] = "gts_id", + ["in"] = "query", + ["description"] = "GTS ID to parse", + ["required"] = true, + ["schema"] = new Dictionary { ["type"] = "string" } + } + } + } + }, + ["/match-id-pattern"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Match a GTS ID against a pattern", + ["operationId"] = "matchIDPattern" + } + }, + ["/uuid"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Generate UUID from a GTS ID", + ["operationId"] = "uuid" + } + }, + ["/validate-instance"] = new Dictionary + { + ["post"] = new Dictionary + { + ["summary"] = "Validate an instance against its schema", + ["operationId"] = "validateInstance" + } + }, + ["/validate-schema"] = new Dictionary + { + ["post"] = new Dictionary + { + ["summary"] = "Validate a schema against ref rules and precedent type chain", + ["operationId"] = "validateSchema" + } + }, + ["/resolve-relationships"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Resolve relationships for an entity", + ["operationId"] = "resolveRelationships" + } + }, + ["/compatibility"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Check compatibility between two schemas", + ["operationId"] = "compatibility" + } + }, + ["/cast"] = new Dictionary + { + ["post"] = new Dictionary + { + ["summary"] = "Cast an instance to a target schema", + ["operationId"] = "cast" + } + }, + ["/query"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Query entities using an expression", + ["operationId"] = "query" + } + }, + ["/attr"] = new Dictionary + { + ["get"] = new Dictionary + { + ["summary"] = "Get attribute value from a GTS entity", + ["operationId"] = "attr" + } + } + } + }; + } +} diff --git a/Gts.Application/GtsQueryEngine.cs b/Gts.Application/GtsQueryEngine.cs new file mode 100644 index 0000000..40abe0d --- /dev/null +++ b/Gts.Application/GtsQueryEngine.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Nodes; +using Gts.Store; + +namespace Gts.Application; + +/// Executes GTS wildcard / filter queries against a registry (same semantics as /query). +public static class GtsQueryEngine +{ + public sealed record QueryResult(List Results, string? Error); + + public static async Task ExecuteAsync( + GtsRegistry registry, + string expr, + int limit, + CancellationToken cancellationToken = default) + { + var r = await registry.QueryAsync(expr, limit, cancellationToken).ConfigureAwait(false); + if (r.Error is not null) + return new QueryResult(new List(), r.Error); + var list = new List(r.Results.Count); + foreach (var o in r.Results) + list.Add(o); + return new QueryResult(list, null); + } +} diff --git a/Gts.Application/GtsRegistryBootstrap.cs b/Gts.Application/GtsRegistryBootstrap.cs new file mode 100644 index 0000000..08d0ce9 --- /dev/null +++ b/Gts.Application/GtsRegistryBootstrap.cs @@ -0,0 +1,200 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Application; + +/// Loads JSON entities from disk into an in-memory registry (similar to gts-go -path). +public static class GtsRegistryBootstrap +{ + private static readonly HashSet ExcludeDirs = + new(StringComparer.OrdinalIgnoreCase) { "node_modules", "dist", "build" }; + + private static readonly HashSet AllowedExt = + new(StringComparer.OrdinalIgnoreCase) { ".json", ".jsonc", ".gts" }; + + /// Splits a comma-separated path list and expands leading ~/. + public static IReadOnlyList ParsePathSpec(string? pathSpec) + { + if (string.IsNullOrWhiteSpace(pathSpec)) + return Array.Empty(); + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var parts = pathSpec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var paths = new List(); + foreach (var p in parts) + { + if (string.IsNullOrEmpty(p)) + continue; + if (p.StartsWith("~/", StringComparison.Ordinal) || p.StartsWith("~\\", StringComparison.Ordinal)) + { + if (!string.IsNullOrEmpty(home)) + paths.Add(Path.Combine(home, p[2..])); + else + paths.Add(p); + } + else + { + paths.Add(p); + } + } + + return paths; + } + + /// Reads optional GTS config JSON (entity_id_fields, schema_id_fields) into . + public static GtsExtractOptions LoadExtractOptionsFromConfig(string? configPath) + { + if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath)) + return GtsExtractOptions.Default; + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(configPath)); + var root = doc.RootElement; + string[]? entityFields = null; + string[]? schemaFields = null; + if (root.TryGetProperty("entity_id_fields", out var e) && e.ValueKind == JsonValueKind.Array) + entityFields = e.EnumerateArray().Select(x => x.GetString()).OfType().ToArray(); + if (root.TryGetProperty("schema_id_fields", out var s) && s.ValueKind == JsonValueKind.Array) + schemaFields = s.EnumerateArray().Select(x => x.GetString()).OfType().ToArray(); + + if (entityFields is null && schemaFields is null) + return GtsExtractOptions.Default; + + return new GtsExtractOptions + { + EntityIdPropertyNames = entityFields ?? GtsExtractOptions.Default.EntityIdPropertyNames, + SchemaIdPropertyNames = schemaFields ?? GtsExtractOptions.Default.SchemaIdPropertyNames + }; + } + catch + { + return GtsExtractOptions.Default; + } + } + + /// Recursively collects .json, .jsonc, and .gts files from paths (files or directories). + public static IReadOnlyList CollectJsonFiles(IEnumerable paths) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var collected = new List(); + + foreach (var path in paths) + { + if (string.IsNullOrWhiteSpace(path)) + continue; + string abs; + try + { + abs = Path.GetFullPath(path); + } + catch + { + continue; + } + + if (Directory.Exists(abs)) + WalkDir(abs, collected, seen); + else if (File.Exists(abs) && AllowedExt.Contains(Path.GetExtension(abs))) + TryAddFile(abs, collected, seen); + } + + return collected; + } + + /// Parses JSON files and merges entities into (skips broken files). + public static async Task LoadIntoRegistryAsync( + GtsRegistry registry, + string? pathSpec, + GtsExtractOptions? extractOptions = null, + CancellationToken cancellationToken = default) + { + var paths = ParsePathSpec(pathSpec); + if (paths.Count == 0) + return; + + var files = CollectJsonFiles(paths); + var opt = extractOptions ?? GtsExtractOptions.Default; + var docOptions = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Allow, + AllowTrailingCommas = true + }; + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + await using var stream = File.OpenRead(file); + JsonNode? root; + try + { + using var doc = await JsonDocument.ParseAsync(stream, docOptions, cancellationToken).ConfigureAwait(false); + root = JsonNode.Parse(doc.RootElement.GetRawText()); + } + catch + { + continue; + } + + switch (root) + { + case JsonArray arr: + foreach (var item in arr) + { + if (item is JsonObject jo) + { + var clone = (JsonObject)jo.DeepClone()!; + GtsJsonKeyNormalizer.Apply(clone); + await GtsEntityOperations.TryAddAsync(registry, clone, validate: false, opt, 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; + } + } + } + } + + private static void WalkDir(string dir, List collected, HashSet seen) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(dir)) + { + if (Directory.Exists(entry)) + { + var name = Path.GetFileName(entry); + if (ExcludeDirs.Contains(name)) + continue; + WalkDir(entry, collected, seen); + } + else if (File.Exists(entry) && AllowedExt.Contains(Path.GetExtension(entry))) + { + TryAddFile(entry, collected, seen); + } + } + } + + private static void TryAddFile(string filePath, List collected, HashSet seen) + { + try + { + var real = Path.GetFullPath(filePath); + if (seen.Add(real)) + collected.Add(real); + } + catch + { + if (seen.Add(filePath)) + collected.Add(filePath); + } + } +} diff --git a/Gts.Application/GtsSchemaGraphBuilder.cs b/Gts.Application/GtsSchemaGraphBuilder.cs new file mode 100644 index 0000000..92c3718 --- /dev/null +++ b/Gts.Application/GtsSchemaGraphBuilder.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Nodes; +using Gts; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Application; + +/// Builds a nested JSON graph of GTS references (same shape as /resolve-relationships). +public static class GtsSchemaGraphBuilder +{ + public static async Task BuildAsync(GtsRegistry registry, string gtsId, CancellationToken cancellationToken = default) + { + var seen = new HashSet(StringComparer.Ordinal); + return await Node(registry, gtsId, seen).ConfigureAwait(false); + + async Task Node(GtsRegistry reg, string id, HashSet seenSet) + { + cancellationToken.ThrowIfCancellationRequested(); + var ret = new JsonObject { ["id"] = id }; + if (!seenSet.Add(id)) + return ret; + + GtsJsonEntity? entity = null; + if (GtsId.TryParse(id, out var gid) && gid is not null) + entity = await reg.GetAsync(gid).ConfigureAwait(false); + if (entity is null) + entity = await reg.GetByInstanceIdAsync(id).ConfigureAwait(false); + + if (entity is null) + { + ret["errors"] = "Entity not found"; + return ret; + } + + var refsObj = new JsonObject(); + foreach (var r in entity.GtsRefs) + { + if (r.Id == id) + continue; + if (r.Id.StartsWith("http://json-schema.org", StringComparison.Ordinal) || + r.Id.StartsWith("https://json-schema.org", StringComparison.Ordinal)) + continue; + refsObj[r.SourcePath] = await Node(reg, r.Id, seenSet).ConfigureAwait(false); + } + + if (refsObj.Count > 0) + ret["refs"] = refsObj; + + if (!string.IsNullOrEmpty(entity.SchemaId) && + !entity.SchemaId.StartsWith("http://json-schema.org", StringComparison.Ordinal) && + !entity.SchemaId.StartsWith("https://json-schema.org", StringComparison.Ordinal)) + ret["schema_id"] = await Node(reg, entity.SchemaId, seenSet).ConfigureAwait(false); + + return ret; + } + } +} diff --git a/Gts.Application/GtsSegmentDto.cs b/Gts.Application/GtsSegmentDto.cs new file mode 100644 index 0000000..b394f47 --- /dev/null +++ b/Gts.Application/GtsSegmentDto.cs @@ -0,0 +1,18 @@ +using Gts; + +namespace Gts.Application; + +/// Maps to JSON-friendly segment objects (snake_case fields). +public static class GtsSegmentDto +{ + public static object FromSegment(GtsIdSegment s) => new + { + vendor = s.Vendor, + package = s.Package, + @namespace = s.Namespace, + type = s.Type, + ver_major = s.VersionMajor, + ver_minor = s.VersionMinor, + is_type = s.IsType + }; +} diff --git a/Gts.Cli/Gts.Cli.csproj b/Gts.Cli/Gts.Cli.csproj new file mode 100644 index 0000000..65255d8 --- /dev/null +++ b/Gts.Cli/Gts.Cli.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + Gts.Cli + gts + + + + + + + + diff --git a/Gts.Cli/Program.cs b/Gts.Cli/Program.cs new file mode 100644 index 0000000..95da784 --- /dev/null +++ b/Gts.Cli/Program.cs @@ -0,0 +1,691 @@ +using System.Reflection; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Gts; +using Gts.Application; +using Gts.Extraction; +using Gts.Store; +using Microsoft.AspNetCore.Builder; + +static class Program +{ + private static readonly JsonSerializerOptions JsonStdout = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private static int _verbose; + private static string? _path; + private static string? _config; + + private static async Task Main(string[] args) + { + if (args.Length == 0) + { + Usage(); + return 2; + } + + ParseGlobalArgs(args, out var cmdIndex); + ApplyEnvironmentDefaults(); + if (cmdIndex >= args.Length) + { + Usage(); + return 2; + } + + var cmd = args[cmdIndex]; + var cmdArgs = cmdIndex + 1 < args.Length ? args[(cmdIndex + 1)..] : Array.Empty(); + + try + { + return cmd switch + { + "validate-id" => RunValidateId(cmdArgs), + "parse-id" => RunParseId(cmdArgs), + "match-id-pattern" => RunMatchIdPattern(cmdArgs), + "uuid" => RunUuid(cmdArgs), + "validate" => await RunValidateAsync(cmdArgs).ConfigureAwait(false), + "relationships" => await RunRelationshipsAsync(cmdArgs).ConfigureAwait(false), + "compatibility" => await RunCompatibilityAsync(cmdArgs).ConfigureAwait(false), + "cast" => await RunCastAsync(cmdArgs).ConfigureAwait(false), + "query" => await RunQueryAsync(cmdArgs).ConfigureAwait(false), + "attr" => await RunAttrAsync(cmdArgs).ConfigureAwait(false), + "list" => await RunListAsync(cmdArgs).ConfigureAwait(false), + "server" => await RunServerAsync(cmdArgs).ConfigureAwait(false), + "openapi" => RunOpenApi(cmdArgs), + "version" => RunVersion(), + "help" or "-h" or "--help" => UsageOk(), + _ => Unknown(cmd) + }; + } + catch (Exception ex) + { + if (_verbose > 0) + Console.Error.WriteLine(ex); + Fatalf(ex.Message); + return 1; + } + } + + private static void ApplyEnvironmentDefaults() + { + if (string.IsNullOrEmpty(_path)) + { + var p = Environment.GetEnvironmentVariable("GTS_PATH"); + if (!string.IsNullOrEmpty(p)) + _path = p; + } + + if (string.IsNullOrEmpty(_config)) + { + var c = Environment.GetEnvironmentVariable("GTS_CONFIG"); + if (!string.IsNullOrEmpty(c)) + _config = c; + } + + if (_verbose == 0) + { + var v = Environment.GetEnvironmentVariable("GTS_VERBOSE"); + if (!string.IsNullOrEmpty(v) && int.TryParse(v, out var vi)) + _verbose = vi; + } + } + + private static void ParseGlobalArgs(string[] args, out int cmdIndex) + { + cmdIndex = 0; + while (cmdIndex < args.Length) + { + var a = args[cmdIndex]; + if (a is "-v" or "--verbose") + { + _verbose++; + cmdIndex++; + continue; + } + + if (a.StartsWith("-v", StringComparison.Ordinal) && a.Length > 2 && int.TryParse(a[2..], out var vn)) + { + _verbose += vn; + cmdIndex++; + continue; + } + + if ((a is "-path" or "--path") && cmdIndex + 1 < args.Length) + { + _path = args[cmdIndex + 1]; + cmdIndex += 2; + continue; + } + + if ((a is "-config" or "--config") && cmdIndex + 1 < args.Length) + { + _config = args[cmdIndex + 1]; + cmdIndex += 2; + continue; + } + + if (a.StartsWith('-')) + { + cmdIndex++; + continue; + } + + break; + } + } + + private static int UsageOk() + { + Console.Error.Write(UsageText); + return 0; + } + + private static void Usage() + { + Console.Error.Write(UsageText); + } + + private const string UsageText = """ +GTS is a tool for working with Global Type System identifiers and schemas. + +Usage: + + gts [arguments] + +Global options (before the command): + + -path Comma-separated JSON / schema files or directories + -config Optional JSON config (entity_id_fields, schema_id_fields) + -v, --verbose Verbose logging (repeat or use -v2) + +Commands: + + validate-id validate a GTS ID format + parse-id parse a GTS ID into its components + match-id-pattern match a GTS ID against a pattern + uuid deterministic UUID from a GTS ID + validate validate a stored instance against its schema + relationships resolve relationships for an entity + compatibility check compatibility between two schemas + cast cast an instance to a target schema + query query entities using an expression + attr get attribute value from a GTS entity + list list entities in the registry + server start the GTS HTTP server + openapi write OpenAPI specification JSON to a file + version print version + help show this message + +Examples: + + gts validate-id -id gts.vendor.pkg.ns.type.v1~ + gts -path ./examples validate -id gts.vendor.pkg.ns.type.v1.0 + gts -path ./examples server -host 127.0.0.1 -port 8000 + +"""; + + private static int Unknown(string cmd) + { + Console.Error.WriteLine($"gts: unknown command \"{cmd}\""); + Console.Error.WriteLine("Run 'gts help' for usage."); + return 2; + } + + private static void Fatalf(string message) + { + Console.Error.WriteLine("gts: " + message); + } + + private static void WriteJson(object value) => + Console.WriteLine(JsonSerializer.Serialize(value, JsonStdout)); + + private static Dictionary ParseKvFlags(string[] args) + { + var d = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + if (!a.StartsWith('-', StringComparison.Ordinal)) + continue; + var key = a.TrimStart('-'); + if (i + 1 < args.Length && !args[i + 1].StartsWith('-', StringComparison.Ordinal)) + d[key] = args[++i]; + else + d[key] = "true"; + } + + return d; + } + + private static void RequirePath() + { + if (string.IsNullOrWhiteSpace(_path)) + throw new InvalidOperationException("command requires -path to load entities"); + } + + private static async Task CreateRegistryAsync(CancellationToken cancellationToken = default) + { + RequirePath(); + var reg = GtsRegistry.InMemoryThreadSafe(new GtsRegistryConfig(false)); + var extract = GtsRegistryBootstrap.LoadExtractOptionsFromConfig(_config); + await GtsRegistryBootstrap.LoadIntoRegistryAsync(reg, _path, extract, cancellationToken).ConfigureAwait(false); + if (_verbose > 0) + { + var n = await reg.CountAsync().ConfigureAwait(false); + Console.Error.WriteLine($"gts: loaded path(s) '{_path}', entity count: {n}"); + } + + return reg; + } + + private static int RunValidateId(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("id", out var id) || string.IsNullOrEmpty(id)) + { + Console.Error.WriteLine("usage: gts validate-id -id "); + return 2; + } + + var isWildcard = id.Contains('*', StringComparison.Ordinal); + if (isWildcard) + { + var pr = GtsId.TryParsePattern(id, out var pat); + if (!pr || pat is null) + { + WriteJson(new + { + id, + valid = false, + is_schema = false, + is_wildcard = true, + error = $"Unable to validate GTS ID '{id}': Invalid wildcard pattern" + }); + return 0; + } + + var isSchema = id.EndsWith("~*", StringComparison.Ordinal) || id.EndsWith(".*", StringComparison.Ordinal); + WriteJson(new { id, valid = true, is_schema = isSchema, is_wildcard = true, error = "" }); + return 0; + } + + if (!GtsId.TryParse(id, out var gid) || gid is null) + { + WriteJson(new + { + id, + valid = false, + is_schema = false, + is_wildcard = false, + error = $"Unable to validate GTS ID '{id}': Invalid GTS id" + }); + return 0; + } + + WriteJson(new { id, valid = true, is_schema = gid.IsType, is_wildcard = false, error = "" }); + return 0; + } + + private static int RunParseId(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("id", out var id) || string.IsNullOrEmpty(id)) + { + Console.Error.WriteLine("usage: gts parse-id -id "); + return 2; + } + + var isWildcard = id.Contains('*', StringComparison.Ordinal); + if (isWildcard) + { + if (!GtsId.TryParsePattern(id, out var pat) || pat is null) + { + WriteJson(new + { + id, + ok = false, + is_wildcard = true, + is_schema = false, + segments = (object?)null, + error = "Invalid pattern" + }); + return 0; + } + + var wildcardIsSchema = id.EndsWith(".*", StringComparison.Ordinal) || id.EndsWith("~*", StringComparison.Ordinal); + WriteJson(new + { + id, + ok = true, + is_wildcard = true, + is_schema = wildcardIsSchema, + segments = pat.Segments.Select(GtsSegmentDto.FromSegment).ToList(), + error = "" + }); + return 0; + } + + if (!GtsId.TryParse(id, out var gid) || gid is null) + { + WriteJson(new + { + id, + ok = false, + is_wildcard = false, + is_schema = false, + segments = (object?)null, + error = "Parse error" + }); + return 0; + } + + WriteJson(new + { + id, + ok = true, + is_wildcard = false, + is_schema = gid.IsType, + segments = gid.Segments.Select(GtsSegmentDto.FromSegment).ToList(), + error = "" + }); + return 0; + } + + private static int RunMatchIdPattern(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("pattern", out var pattern) || string.IsNullOrEmpty(pattern) || + !f.TryGetValue("candidate", out var candidate) || string.IsNullOrEmpty(candidate)) + { + Console.Error.WriteLine("usage: gts match-id-pattern -pattern -candidate "); + return 2; + } + + 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) + { + WriteJson(new { candidate, pattern, match = false, error = "invalid pattern" }); + return 0; + } + + WriteJson(new { candidate, pattern, match = cPat.Matches(pPat), error = "" }); + return 0; + } + + if (!GtsId.TryParse(candidate, out var cId) || cId is null) + { + WriteJson(new { candidate, pattern, match = false, error = "invalid candidate" }); + return 0; + } + + WriteJson(new { candidate, pattern, match = cId.Matches(pattern), error = "" }); + return 0; + } + catch (Exception ex) + { + WriteJson(new { candidate, pattern, match = false, error = ex.Message }); + return 0; + } + } + + private static int RunUuid(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("id", out var id) || string.IsNullOrEmpty(id)) + { + Console.Error.WriteLine("usage: gts uuid -id "); + return 2; + } + + if (!GtsId.TryParse(id, out var gid) || gid is null) + { + WriteJson(new { id, uuid = "", error = "Invalid GTS id" }); + return 0; + } + + WriteJson(new { id = gid.Id, uuid = gid.ToGuid().ToString(), error = "" }); + return 0; + } + + private static async Task RunValidateAsync(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("id", out var instanceId) || string.IsNullOrEmpty(instanceId)) + { + Console.Error.WriteLine("usage: gts validate -id "); + return 2; + } + + 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") }); + return 0; + } + + private static async Task RunRelationshipsAsync(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("id", out var id) || string.IsNullOrEmpty(id)) + { + Console.Error.WriteLine("usage: gts relationships -id "); + return 2; + } + + var reg = await CreateRegistryAsync().ConfigureAwait(false); + var graph = await GtsSchemaGraphBuilder.BuildAsync(reg, id).ConfigureAwait(false); + WriteJson(graph); + return 0; + } + + private static async Task RunCompatibilityAsync(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("old", out var oldId) || string.IsNullOrEmpty(oldId) || + !f.TryGetValue("new", out var newId) || string.IsNullOrEmpty(newId)) + { + Console.Error.WriteLine("usage: gts compatibility -old -new "); + return 2; + } + + var reg = await CreateRegistryAsync().ConfigureAwait(false); + if (!GtsId.TryParse(oldId, out var o) || o is null || !GtsId.TryParse(newId, out var n) || n is null) + { + WriteJson(new + { + old = oldId, + @new = newId, + is_backward_compatible = false, + is_forward_compatible = false, + is_fully_compatible = false, + backward_errors = new[] { "Invalid id" }, + forward_errors = new[] { "Invalid id" } + }); + return 0; + } + + var a = await reg.GetAsync(o).ConfigureAwait(false); + var b = await reg.GetAsync(n).ConfigureAwait(false); + if (a is null || b is null || !a.IsSchema || !b.IsSchema) + { + WriteJson(new + { + old = oldId, + @new = newId, + is_backward_compatible = false, + is_forward_compatible = false, + is_fully_compatible = false, + backward_errors = new[] { "Schema not found" }, + forward_errors = new[] { "Schema not found" } + }); + return 0; + } + + 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); + + WriteJson(new + { + old = oldId, + @new = newId, + is_backward_compatible = backOk, + is_forward_compatible = fwdOk, + is_fully_compatible = backOk && fwdOk, + backward_errors = backErr, + forward_errors = fwdErr + }); + return 0; + } + + private static async Task RunCastAsync(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("from", out var fromId) || string.IsNullOrEmpty(fromId) || + !f.TryGetValue("to", out var toSchemaId) || string.IsNullOrEmpty(toSchemaId)) + { + Console.Error.WriteLine("usage: gts cast -from -to "); + return 2; + } + + var reg = await CreateRegistryAsync().ConfigureAwait(false); + if (!GtsId.TryParse(toSchemaId, out var toGid) || toGid is null || !toGid.IsType) + { + WriteJson(new { error = "Invalid target schema id" }); + return 1; + } + + var result = await reg.CastInstanceAsync(fromId, toGid).ConfigureAwait(false); + if (!result.Ok) + { + WriteJson(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()), + comparison = result.Comparison is null + ? null + : new + { + 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 1; + } + + WriteJson(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 + }); + return 0; + } + + private static async Task RunQueryAsync(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("expr", out var expr) || string.IsNullOrEmpty(expr)) + { + Console.Error.WriteLine("usage: gts query -expr [-limit n]"); + return 2; + } + + var limit = f.TryGetValue("limit", out var ls) && int.TryParse(ls, out var li) ? li : 100; + var reg = await CreateRegistryAsync().ConfigureAwait(false); + var result = await GtsQueryEngine.ExecuteAsync(reg, expr, limit).ConfigureAwait(false); + if (result.Error is not null) + WriteJson(new { error = result.Error, count = 0, limit, results = Array.Empty() }); + else + WriteJson(new { error = "", count = result.Results.Count, limit, results = result.Results }); + return 0; + } + + private static async Task RunAttrAsync(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("path", out var pathArg) || string.IsNullOrEmpty(pathArg)) + { + Console.Error.WriteLine("usage: gts attr -path "); + return 2; + } + + var reg = await CreateRegistryAsync().ConfigureAwait(false); + var r = await reg.GetAttributeAsync(pathArg).ConfigureAwait(false); + if (!r.Resolved) + { + WriteJson(new { resolved = false, error = r.Error, available_fields = r.AvailableFields }); + return 0; + } + + object payload = r.Value switch + { + JsonValue jv when jv.TryGetValue(out var s) => s, + JsonValue jv when jv.TryGetValue(out var b) => b, + JsonValue jv when jv.TryGetValue(out var ni) => ni, + 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())! + }; + WriteJson(new { resolved = true, value = payload }); + return 0; + } + + private static async Task RunListAsync(string[] args) + { + var f = ParseKvFlags(args); + var limit = f.TryGetValue("limit", out var ls) && int.TryParse(ls, out var li) ? li : 100; + var reg = await CreateRegistryAsync().ConfigureAwait(false); + var all = await reg.GetAllAsync().ConfigureAwait(false); + var withId = all.Where(e => e.GtsId is not null).ToList(); + var total = withId.Count; + var list = withId.Take(limit) + .Select(e => new { id = e.GtsId!.Id, schema_id = string.IsNullOrEmpty(e.SchemaId) ? "" : e.SchemaId, is_schema = e.IsSchema }) + .ToList(); + WriteJson(new { entities = list, count = list.Count, total }); + return 0; + } + + private static async Task RunServerAsync(string[] args) + { + var f = ParseKvFlags(args); + var host = f.GetValueOrDefault("host", "127.0.0.1"); + var port = f.TryGetValue("port", out var ps) && int.TryParse(ps, out var p) ? p : 8000; + + var builder = WebApplication.CreateBuilder(args); + builder.WebHost.UseUrls($"http://{host}:{port}"); + builder.Services.Configure(o => + { + o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + + var reg = GtsRegistry.InMemoryThreadSafe(new GtsRegistryConfig(false)); + var extract = GtsRegistryBootstrap.LoadExtractOptionsFromConfig(_config); + if (!string.IsNullOrWhiteSpace(_path)) + await GtsRegistryBootstrap.LoadIntoRegistryAsync(reg, _path, extract).ConfigureAwait(false); + + var app = builder.Build(); + app.MapGtsApi(reg); + + Console.WriteLine($"starting server at http://{host}:{port}"); + if (_verbose == 0) + Console.WriteLine("use -v for verbose logging"); + + await app.RunAsync().ConfigureAwait(false); + return 0; + } + + private static int RunOpenApi(string[] args) + { + var f = ParseKvFlags(args); + if (!f.TryGetValue("out", out var outPath) || string.IsNullOrEmpty(outPath)) + { + Console.Error.WriteLine("usage: gts openapi -out [-host address] [-port number]"); + return 2; + } + + var host = f.GetValueOrDefault("host", "127.0.0.1"); + var port = f.TryGetValue("port", out var ps) && int.TryParse(ps, out var p) ? p : 8000; + var spec = GtsOpenApiSpec.Build(host, port); + File.WriteAllText(outPath, JsonSerializer.Serialize(spec, JsonStdout)); + WriteJson(new Dictionary { ["ok"] = true, ["out"] = outPath }); + return 0; + } + + private static int RunVersion() + { + var asm = typeof(GtsId).Assembly; + var info = asm.GetCustomAttribute()?.InformationalVersion + ?? asm.GetName().Version?.ToString() + ?? "unknown"; + Console.WriteLine("gts version " + info); + if (_verbose > 0) + { + Console.WriteLine("runtime " + System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription); + Console.WriteLine("assembly " + asm.FullName); + } + + return 0; + } +} diff --git a/Gts.Server/Gts.Server.csproj b/Gts.Server/Gts.Server.csproj new file mode 100644 index 0000000..106f729 --- /dev/null +++ b/Gts.Server/Gts.Server.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + Gts.Server + + + + + + + + diff --git a/Gts.Server/Program.cs b/Gts.Server/Program.cs new file mode 100644 index 0000000..b003e5d --- /dev/null +++ b/Gts.Server/Program.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Gts.Application; +using Gts.Store; +using Microsoft.AspNetCore.Http.Json; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://127.0.0.1:8000"); + +builder.Services.Configure(o => +{ + o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; +}); + +var app = builder.Build(); + +var registry = GtsRegistry.InMemoryThreadSafe(new GtsRegistryConfig(false)); +app.MapGtsApi(registry); +app.Run(); diff --git a/Gts.Server/Properties/launchSettings.json b/Gts.Server/Properties/launchSettings.json new file mode 100644 index 0000000..1617362 --- /dev/null +++ b/Gts.Server/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Gts.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://127.0.0.1:8000" + } + } +} diff --git a/Gts.Store/Gts.Store.csproj b/Gts.Store/Gts.Store.csproj new file mode 100644 index 0000000..54b0e1b --- /dev/null +++ b/Gts.Store/Gts.Store.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Gts.Store/GtsAttributeAccessResult.cs b/Gts.Store/GtsAttributeAccessResult.cs new file mode 100644 index 0000000..4cdd417 --- /dev/null +++ b/Gts.Store/GtsAttributeAccessResult.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// Outcome of attribute resolution (id@path, OP#11). +public sealed record GtsAttributeAccessResult( + bool Resolved, + JsonNode? Value, + string? InstanceId, + string? AttributePath, + string? Error, + IReadOnlyList? AvailableFields); diff --git a/Gts.Store/GtsAttributeSelector.cs b/Gts.Store/GtsAttributeSelector.cs new file mode 100644 index 0000000..36a9d6f --- /dev/null +++ b/Gts.Store/GtsAttributeSelector.cs @@ -0,0 +1,203 @@ +using System.Text; +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// Resolves @path selectors against JSON content (OP#11). Path rules align with gts-go parsePath / resolveAttributePath. +public static class GtsAttributeSelector +{ + /// Splits at the first @ into instance id and attribute path (both trimmed). + public static (string? GtsId, string? JsonPath) SplitGtsWithPath(string gtsWithPath) + { + var at = gtsWithPath.IndexOf('@'); + if (at < 0) + return (gtsWithPath.Trim(), null); + return (gtsWithPath[..at].Trim(), gtsWithPath[(at + 1)..].Trim()); + } + + /// Walks following a dotted path; / is treated like .; supports prop[n] and chained prop[0][1]. + public static bool TryResolve(JsonNode? root, string jsonPath, out JsonNode? value) + { + value = null; + if (root is null) + return false; + return TryResolvePath(root, jsonPath, out value, out _, out _); + } + + /// Like but returns diagnostics when resolution fails. + public static bool TryResolvePath( + JsonNode root, + string path, + out JsonNode? value, + out string? error, + out IReadOnlyList? availableFields) + { + value = null; + error = null; + availableFields = null; + + var parts = ParsePath(path); + JsonNode? current = root; + foreach (var part in parts) + { + if (current is JsonObject obj) + { + if (part.Length >= 2 && part[0] == '[' && part[^1] == ']') + { + error = $"Path not found at segment '{part}' in '{path}', see available fields"; + availableFields = CollectAvailableFields(obj, ""); + return false; + } + + if (!obj.TryGetPropertyValue(part, out var next)) + { + error = $"Path not found at segment '{part}' in '{path}', see available fields"; + availableFields = CollectAvailableFields(obj, ""); + return false; + } + + current = next; + } + else if (current is JsonArray arr) + { + if (!TryParseArrayIndex(part, out var idx)) + { + error = $"Expected list index at segment '{part}'"; + availableFields = CollectAvailableFieldsFromArray(arr, ""); + return false; + } + + if (idx < 0 || idx >= arr.Count) + { + error = $"Index out of range at segment '{part}'"; + availableFields = CollectAvailableFieldsFromArray(arr, ""); + return false; + } + + current = arr[idx]; + } + else + { + error = $"Cannot descend into {DescribeNodeKind(current)} at segment '{part}'"; + return false; + } + } + + value = current; + return true; + } + + private static List ParsePath(string path) + { + var normalized = path.Replace('/', '.'); + var rawParts = new List(); + foreach (var seg in normalized.Split('.')) + { + if (seg.Length > 0) + rawParts.Add(seg); + } + + var parts = new List(); + foreach (var seg in rawParts) + parts.AddRange(ParsePathSegment(seg)); + + return parts; + } + + private static List ParsePathSegment(string seg) + { + var result = new List(); + var buf = new StringBuilder(); + for (var i = 0; i < seg.Length;) + { + if (seg[i] == '[') + { + if (buf.Length > 0) + { + result.Add(buf.ToString()); + buf.Clear(); + } + + var close = seg.IndexOf(']', i); + if (close < 0) + { + buf.Append(seg.AsSpan(i)); + break; + } + + result.Add(seg.Substring(i, close - i + 1)); + i = close + 1; + } + else + { + buf.Append(seg[i]); + i++; + } + } + + if (buf.Length > 0) + result.Add(buf.ToString()); + + return result; + } + + private static bool TryParseArrayIndex(string part, out int idx) + { + idx = 0; + if (part.Length >= 2 && part[0] == '[' && part[^1] == ']') + return int.TryParse(part.AsSpan(1, part.Length - 2), out idx); + return int.TryParse(part, out idx); + } + + private static List CollectAvailableFields(JsonObject node, string prefix) + { + var fields = new List(); + foreach (var (key, val) in node) + { + var p = string.IsNullOrEmpty(prefix) ? key : prefix + "." + key; + fields.Add(p); + switch (val) + { + case JsonObject o: + fields.AddRange(CollectAvailableFields(o, p)); + break; + case JsonArray a: + fields.AddRange(CollectAvailableFieldsFromArray(a, p)); + break; + } + } + + return fields; + } + + private static List CollectAvailableFieldsFromArray(JsonArray node, string prefix) + { + var fields = new List(); + for (var i = 0; i < node.Count; i++) + { + var p = $"{prefix}[{i}]"; + fields.Add(p); + switch (node[i]) + { + case JsonObject o: + fields.AddRange(CollectAvailableFields(o, p)); + break; + case JsonArray a: + fields.AddRange(CollectAvailableFieldsFromArray(a, p)); + break; + } + } + + return fields; + } + + private static string DescribeNodeKind(JsonNode? n) => + n switch + { + null => "null", + JsonValue => "scalar", + JsonArray => "array", + JsonObject => "object", + _ => n.GetType().Name + }; +} diff --git a/Gts.Store/GtsBrokenReference.cs b/Gts.Store/GtsBrokenReference.cs new file mode 100644 index 0000000..a1d895d --- /dev/null +++ b/Gts.Store/GtsBrokenReference.cs @@ -0,0 +1,16 @@ +namespace Gts.Store; + +/// +/// A GTS identifier referenced from a stored entity that does not resolve to another stored entity. +/// +/// Primary id of the referencing entity (GTS id or opaque id such as a UUID). +/// True if the source document is a JSON Schema. +/// The GTS identifier that could not be resolved. +/// JSON path where the reference was found (see ). +/// Machine-readable reason, e.g. MissingSchema, MissingInstance, MissingTypeBinding. +public sealed record GtsBrokenReference( + string SourceId, + bool SourceIsSchema, + string ReferencedId, + string SourcePath, + string Reason); diff --git a/Gts.Store/GtsInstanceCast.cs b/Gts.Store/GtsInstanceCast.cs new file mode 100644 index 0000000..dc7f038 --- /dev/null +++ b/Gts.Store/GtsInstanceCast.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// Transforms JSON instances toward a target JSON Schema (GTS cast / minor version migration). +public static class GtsInstanceCast +{ + /// Returns a deep-cloned instance updated toward . + public static JsonObject CastToEffectiveSchema(JsonObject instance, JsonObject targetEffective) + { + var result = (JsonObject)instance.DeepClone()!; + CastObject(result, targetEffective, ""); + return result; + } + + private static void CastObject(JsonObject result, JsonObject schema, string basePath) + { + var targetProps = schema["properties"] as JsonObject ?? new JsonObject(); + var required = new HashSet(StringComparer.Ordinal); + if (schema["required"] is JsonArray rq) + { + foreach (var x in rq) + { + if (x is JsonValue jv && jv.TryGetValue(out var name)) + required.Add(name); + } + } + + var additional = true; + if (schema.TryGetPropertyValue("additionalProperties", out var apNode) && apNode is JsonValue apv) + { + if (apv.TryGetValue(out var ab)) + additional = ab; + } + + foreach (var prop in required) + { + if (result.TryGetPropertyValue(prop, out _)) + continue; + + if (targetProps.TryGetPropertyValue(prop, out var pSchema) && pSchema is JsonObject pso && + pso.TryGetPropertyValue("default", out var def)) + { + result[prop] = def.DeepClone(); + } + } + + foreach (var (prop, pSchema) in targetProps) + { + if (required.Contains(prop)) + continue; + if (result.ContainsKey(prop)) + continue; + if (pSchema is JsonObject pso && pso.TryGetPropertyValue("default", out var def)) + result[prop] = def.DeepClone(); + } + + foreach (var (prop, pSchema) in targetProps) + { + if (pSchema is not JsonObject pso) + continue; + if (pso.TryGetPropertyValue("const", out var c) && c is JsonValue cv && result.TryGetPropertyValue(prop, out var existing)) + { + if (cv.TryGetValue(out var constStr) && existing is JsonValue ev && + ev.TryGetValue(out var oldStr) && + GtsId.TryParse(constStr, out _) && GtsId.TryParse(oldStr, out _) && constStr != oldStr) + result[prop] = JsonValue.Create(constStr); + } + } + + if (additional is false) + { + var toRemove = result.Select(p => p.Key).Where(k => !targetProps.ContainsKey(k)).ToList(); + foreach (var k in toRemove) + result.Remove(k); + } + + foreach (var (prop, pSchema) in targetProps) + { + if (!result.TryGetPropertyValue(prop, out var val)) + continue; + if (pSchema is not JsonObject pso) + continue; + + var pType = pso["type"] is JsonValue tv && tv.TryGetValue(out var ts) ? ts : null; + if (pType == "object" && val is JsonObject vo) + { + var nested = EffectiveObjectSchema(pso); + CastObject(vo, nested, string.IsNullOrEmpty(basePath) ? prop : basePath + "." + prop); + } + else if (pType == "array" && val is JsonArray arr && + pso["items"] is JsonObject items && items["type"] is JsonValue itv && + itv.TryGetValue(out var it) && it == "object") + { + var nested = EffectiveObjectSchema(items); + for (var i = 0; i < arr.Count; i++) + { + if (arr[i] is JsonObject itemObj) + CastObject(itemObj, nested, $"{basePath}.{prop}[{i}]"); + } + } + } + } + + private static JsonObject EffectiveObjectSchema(JsonObject s) + { + if (s["properties"] is JsonObject || s["required"] is JsonArray) + return s; + if (s["allOf"] is JsonArray allof) + { + foreach (var part in allof) + { + if (part is JsonObject po && (po["properties"] is JsonObject || po["required"] is JsonArray)) + return po; + } + } + + return s; + } + + /// Removes const where the value is a GTS id string, for tolerant validation. + public static JsonNode RemoveGtsConstConstraints(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + var copy = new JsonObject(); + foreach (var (k, v) in obj) + { + if (k == "const" && v is JsonValue jv && jv.TryGetValue(out var s) && GtsId.TryParse(s, out _)) + { + copy["type"] = "string"; + continue; + } + + copy[k] = RemoveGtsConstConstraints(v); + } + + return copy; + case JsonArray arr: + var na = new JsonArray(); + foreach (var item in arr) + na.Add(RemoveGtsConstConstraints(item)); + return na; + case JsonValue: + return node.DeepClone(); + default: + return node?.DeepClone() ?? JsonValue.Create((object?)null)!; + } + } + + internal static JsonDocument ToJsonDocument(JsonObject o) + => JsonDocument.Parse(o.ToJsonString()); +} diff --git a/Gts.Store/GtsInstanceCastResult.cs b/Gts.Store/GtsInstanceCastResult.cs new file mode 100644 index 0000000..bcc950a --- /dev/null +++ b/Gts.Store/GtsInstanceCastResult.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// +/// Outcome of casting a stored JSON instance to another minor variant of the same GTS type (OP#9). +/// +public sealed class GtsInstanceCastResult +{ + /// True when the instance was transformed and validates against the target schema (with GTS const tolerance). + public bool Ok { get; init; } + + /// The instance id passed to the cast (GTS instance id or opaque id). + public string? InstanceId { get; init; } + + /// Schema the instance was bound to before casting. + public GtsId? FromSchemaId { get; init; } + + /// Target schema type id. + public GtsId? ToSchemaId { get; init; } + + /// High-level failure code when is false; null on success. + public string? FailureReason { get; init; } + + /// Deep-cloned instance updated toward the target effective schema; null when is false. + public JsonObject? CastedContent { get; init; } + + /// + /// Structural and evolution comparison between source and target schemas; null when schemas could not be compared + /// (e.g. missing entity). + /// + public GtsMinorVersionPairComparison? Comparison { get; init; } + + /// Flattened JSON Schema validation messages when is CastValidationFailed. + public IReadOnlyList? SchemaValidationErrors { get; init; } +} diff --git a/Gts.Store/GtsInstanceValidationResult.cs b/Gts.Store/GtsInstanceValidationResult.cs new file mode 100644 index 0000000..711bef6 --- /dev/null +++ b/Gts.Store/GtsInstanceValidationResult.cs @@ -0,0 +1,19 @@ +namespace Gts.Store; + +/// +/// Outcome of validating a JSON instance against its resolved GTS JSON Schema (OP#6). +/// +public sealed class GtsInstanceValidationResult +{ + /// True when the instance exists, is not a schema document, and conforms to its schema. + public bool Ok { get; init; } + + /// The instance id that was validated (GTS instance id or opaque id such as a UUID). + public string? Id { get; init; } + + /// High-level failure code when is false; null on success. + public string? FailureReason { get; init; } + + /// Flattened JSON Schema validation messages when is SchemaValidationFailed. + public IReadOnlyList? SchemaErrors { get; init; } +} diff --git a/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs b/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs new file mode 100644 index 0000000..83d8bfe --- /dev/null +++ b/Gts.Store/GtsJsonSchemaEvolutionCompatibility.cs @@ -0,0 +1,300 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// +/// JSON Schema minor-evolution compatibility checks (backward / forward) aligned with the GTS reference implementation. +/// +public static class GtsJsonSchemaEvolutionCompatibility +{ + /// Backward: instances valid under remain valid under . + public static (bool Ok, IReadOnlyList Errors) CheckBackward(JsonObject oldSchema, JsonObject newSchema) + => CheckSchemaCompatibility(oldSchema, newSchema, checkBackward: true); + + /// Forward: instances valid under remain valid under . + public static (bool Ok, IReadOnlyList Errors) CheckForward(JsonObject oldSchema, JsonObject newSchema) + => CheckSchemaCompatibility(oldSchema, newSchema, checkBackward: false); + + private static (bool Ok, IReadOnlyList Errors) CheckSchemaCompatibility( + JsonObject oldSchema, + JsonObject newSchema, + bool checkBackward) + { + var errors = new List(); + var oldFlat = FlattenSchema(oldSchema); + var newFlat = FlattenSchema(newSchema); + + var oldProps = GetProperties(oldFlat); + var newProps = GetProperties(newFlat); + var oldReq = GetRequired(oldFlat); + var newReq = GetRequired(newFlat); + + if (checkBackward) + { + var newlyRequired = newReq.Except(oldReq).ToHashSet(); + if (newlyRequired.Count > 0) + errors.Add($"Added required properties: {string.Join(", ", newlyRequired)}"); + } + else + { + var removedRequired = oldReq.Except(newReq).ToHashSet(); + if (removedRequired.Count > 0) + errors.Add($"Removed required properties: {string.Join(", ", removedRequired)}"); + } + + foreach (var prop in oldProps.Keys.Intersect(newProps.Keys)) + { + var oldPropSchema = AsObject(oldProps[prop]); + var newPropSchema = AsObject(newProps[prop]); + if (oldPropSchema is null || newPropSchema is null) + continue; + + var oldType = GetTypeString(oldPropSchema); + var newType = GetTypeString(newPropSchema); + + if (oldType is not null && newType is not null && oldType != newType) + errors.Add($"Property '{prop}' type changed from {oldType} to {newType}"); + + var oldEnum = oldPropSchema["enum"] as JsonArray; + var newEnum = newPropSchema["enum"] as JsonArray; + if (oldEnum is not null && newEnum is not null) + { + 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)}"); + } + } + + errors.AddRange(CheckConstraintCompatibility(prop, oldPropSchema, newPropSchema, checkBackward)); + + if (oldType == "object" && newType == "object") + { + var (nestedOk, nestedErrs) = CheckSchemaCompatibility(oldPropSchema, newPropSchema, checkBackward); + if (!nestedOk) + { + foreach (var err in nestedErrs) + errors.Add($"Property '{prop}': {err}"); + } + } + + if (oldType == "array" && newType == "array") + { + var oi = oldPropSchema["items"] as JsonObject; + var ni = newPropSchema["items"] as JsonObject; + if (oi is not null && ni is not null) + { + var oit = GetTypeString(oi); + var nit = GetTypeString(ni); + if (oit == "object" && nit == "object") + { + var (nestedOk, nestedErrs) = CheckSchemaCompatibility(oi, ni, checkBackward); + if (!nestedOk) + { + foreach (var err in nestedErrs) + errors.Add($"Property '{prop}': {err}"); + } + } + } + } + } + + return (errors.Count == 0, errors); + } + + private static HashSet EnumToStringSet(JsonArray arr) + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var n in arr) + { + if (n is JsonValue v && v.TryGetValue(out var s)) + set.Add(s); + } + + return set; + } + + private static string? GetTypeString(JsonObject propSchema) + { + if (propSchema.TryGetPropertyValue("type", out var t)) + { + if (t is JsonValue jv && jv.TryGetValue(out var s)) + return s; + } + + return null; + } + + private static JsonObject? AsObject(JsonNode? n) => n as JsonObject; + + private static IReadOnlyList CheckConstraintCompatibility( + string prop, + JsonObject oldPropSchema, + JsonObject newPropSchema, + bool checkTightening) + { + var errors = new List(); + var propType = GetTypeString(oldPropSchema); + + if (propType is "number" or "integer") + errors.AddRange(CheckMinMaxConstraint(prop, oldPropSchema, newPropSchema, "minimum", "maximum", checkTightening)); + + if (propType == "string") + errors.AddRange(CheckMinMaxConstraint(prop, oldPropSchema, newPropSchema, "minLength", "maxLength", checkTightening)); + + if (propType == "array") + errors.AddRange(CheckMinMaxConstraint(prop, oldPropSchema, newPropSchema, "minItems", "maxItems", checkTightening)); + + return errors; + } + + private static IReadOnlyList CheckMinMaxConstraint( + string prop, + JsonObject oldSchema, + JsonObject newSchema, + string minKey, + string maxKey, + bool checkTightening) + { + var errors = new List(); + + var oldMin = GetNumber(oldSchema, minKey); + var newMin = GetNumber(newSchema, minKey); + if (oldMin is not null && newMin is not null) + { + if (checkTightening && newMin > oldMin) + errors.Add($"Property '{prop}' {minKey} increased from {oldMin} to {newMin}"); + if (!checkTightening && newMin < oldMin) + errors.Add($"Property '{prop}' {minKey} decreased from {oldMin} to {newMin}"); + } + else if (checkTightening && oldMin is null && newMin is not null) + errors.Add($"Property '{prop}' added {minKey} constraint: {newMin}"); + else if (!checkTightening && oldMin is not null && newMin is null) + errors.Add($"Property '{prop}' removed {minKey} constraint"); + + var oldMax = GetNumber(oldSchema, maxKey); + var newMax = GetNumber(newSchema, maxKey); + if (oldMax is not null && newMax is not null) + { + if (checkTightening && newMax < oldMax) + errors.Add($"Property '{prop}' {maxKey} decreased from {oldMax} to {newMax}"); + if (!checkTightening && newMax > oldMax) + errors.Add($"Property '{prop}' {maxKey} increased from {oldMax} to {newMax}"); + } + else if (checkTightening && oldMax is null && newMax is not null) + errors.Add($"Property '{prop}' added {maxKey} constraint: {newMax}"); + else if (!checkTightening && oldMax is not null && newMax is null) + errors.Add($"Property '{prop}' removed {maxKey} constraint"); + + return errors; + } + + private static decimal? GetNumber(JsonObject o, string key) + { + if (!o.TryGetPropertyValue(key, out var n) || n is not JsonValue jv) + return null; + if (jv.TryGetValue(out long l)) + return l; + if (jv.TryGetValue(out double d)) + return (decimal)d; + if (jv.TryGetValue(out decimal m)) + return m; + return null; + } + + private static Dictionary GetProperties(JsonObject flat) + { + var d = new Dictionary(StringComparer.Ordinal); + if (flat.TryGetPropertyValue("properties", out var p) && p is JsonObject po) + { + foreach (var (k, v) in po) + d[k] = v; + } + + return d; + } + + private static HashSet GetRequired(JsonObject flat) + { + var s = new HashSet(StringComparer.Ordinal); + if (flat.TryGetPropertyValue("required", out var r) && r is JsonArray arr) + { + foreach (var item in arr) + { + if (item is JsonValue jv && jv.TryGetValue(out var name)) + s.Add(name); + } + } + + return s; + } + + /// Merges allOf fragments into a single object shape (properties/required/additionalProperties). + public static JsonObject FlattenSchema(JsonObject schema) + { + var result = new JsonObject + { + ["properties"] = new JsonObject(), + ["required"] = new JsonArray() + }; + + if (schema.TryGetPropertyValue("allOf", out var allof) && allof is JsonArray parts) + { + foreach (var part in parts) + { + if (part is not JsonObject sub) + continue; + var flattened = FlattenSchema(sub); + MergeFlatInto(result, flattened); + } + } + + if (schema.TryGetPropertyValue("properties", out var props) && props is JsonObject po) + { + var targetProps = (JsonObject)result["properties"]!; + foreach (var (k, v) in po) + targetProps[k] = v?.DeepClone(); + } + + if (schema.TryGetPropertyValue("required", out var req) && req is JsonArray rq) + { + var targetReq = (JsonArray)result["required"]!; + foreach (var item in rq) + targetReq.Add(item?.DeepClone()); + } + + if (schema.TryGetPropertyValue("additionalProperties", out var ap)) + result["additionalProperties"] = ap.DeepClone(); + + return result; + } + + private static void MergeFlatInto(JsonObject target, JsonObject flattened) + { + if (flattened.TryGetPropertyValue("properties", out var fp) && fp is JsonObject fpo) + { + var tp = (JsonObject)target["properties"]!; + foreach (var (k, v) in fpo) + tp[k] = v?.DeepClone(); + } + + if (flattened.TryGetPropertyValue("required", out var fr) && fr is JsonArray fra) + { + var tr = (JsonArray)target["required"]!; + foreach (var item in fra) + tr.Add(item?.DeepClone()); + } + + if (flattened.TryGetPropertyValue("additionalProperties", out var ap)) + target["additionalProperties"] = ap.DeepClone(); + } +} diff --git a/Gts.Store/GtsParsedQuery.cs b/Gts.Store/GtsParsedQuery.cs new file mode 100644 index 0000000..8818a73 --- /dev/null +++ b/Gts.Store/GtsParsedQuery.cs @@ -0,0 +1,4 @@ +namespace Gts.Store; + +/// Parsed GTS query expression: base id pattern (exact or wildcard) and optional JSON property filters. +public sealed record GtsParsedQuery(string BasePattern, bool IsWildcard, IReadOnlyDictionary Filters); diff --git a/Gts.Store/GtsQuery.cs b/Gts.Store/GtsQuery.cs new file mode 100644 index 0000000..eba6660 --- /dev/null +++ b/Gts.Store/GtsQuery.cs @@ -0,0 +1,208 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json.Nodes; +using Gts; +using Gts.Extraction; + +namespace Gts.Store; + +/// +/// GTS query language: base GTS id or wildcard pattern, optional [prop="value", …] filters (AND). +/// Attribute filters are not allowed on type patterns (suffix ~ or ~*). Same semantics as the gts query CLI and GET /query API. +/// +public static class GtsQuery +{ + /// Parses a query expression. Returns false with when the expression is invalid. + public static bool TryParse( + string expr, + [NotNullWhen(true)] out GtsParsedQuery? query, + [NotNullWhen(false)] out string? error) + { + query = null; + error = null; + + if (string.IsNullOrWhiteSpace(expr)) + { + error = "Invalid query"; + return false; + } + + var basePart = expr; + string? filterPart = null; + var idx = expr.IndexOf('[', StringComparison.Ordinal); + if (idx >= 0) + { + if (!expr.EndsWith("]", StringComparison.Ordinal)) + { + error = "Invalid query: missing closing bracket ']'"; + return false; + } + + basePart = expr[..idx].Trim(); + filterPart = expr[(idx + 1)..^1]; + } + + var filters = ParseFilters(filterPart); + if (filters.Count > 0) + { + if (basePart.EndsWith("~", StringComparison.Ordinal) || basePart.EndsWith("~*", StringComparison.Ordinal)) + { + error = "Invalid query: filters cannot be used with type patterns (ending with ~ or ~*)"; + return false; + } + } + + var isWildcard = basePart.Contains('*', StringComparison.Ordinal); + if (isWildcard) + { + if (!(basePart.EndsWith(".*", StringComparison.Ordinal) || basePart.EndsWith("~*", StringComparison.Ordinal))) + { + error = "Invalid query: wildcard patterns must end with .* or ~*"; + return false; + } + + if (!GtsId.TryParsePattern(basePart, out var w) || w is null) + { + error = "Invalid query"; + return false; + } + } + else + { + if (!GtsId.TryParse(basePart, out var exact) || exact is null) + { + error = "Invalid query"; + return false; + } + } + + query = new GtsParsedQuery(basePart, isWildcard, filters); + return true; + } + + /// + /// Returns identifiers from whose string form matches the query base pattern. + /// Fails when the expression includes attribute filters (those require JSON; use or ). + /// + public static bool TryFilterIdentifiers( + string expr, + IEnumerable ids, + int limit, + [NotNullWhen(true)] out List? matched, + [NotNullWhen(false)] out string? error) + { + matched = null; + if (!TryParse(expr, out var q, out var parseError) || q is null) + { + error = parseError ?? "Invalid query"; + return false; + } + + if (q.Filters.Count > 0) + { + error = "Invalid query: attribute filters require entity JSON; use QueryAsync or GtsQuery.Execute with entities."; + return false; + } + + var lim = NormalizeLimit(limit); + var list = new List(); + foreach (var id in ids) + { + if (list.Count >= lim) + break; + if (IdMatches(id, q)) + list.Add(id); + } + + matched = list; + error = null; + return true; + } + + /// Runs a parsed query over in-memory entities (same matching rules as the registry). + public static List Execute( + IEnumerable entities, + GtsParsedQuery query, + int limit, + CancellationToken cancellationToken = default) + { + var lim = NormalizeLimit(limit); + var results = new List(); + foreach (var e in entities) + { + cancellationToken.ThrowIfCancellationRequested(); + if (results.Count >= lim) + break; + if (e.GtsId is null) + continue; + if (!IdMatches(e.GtsId, query)) + continue; + if (!MatchFilters(e.Content, query.Filters)) + continue; + results.Add((JsonObject)JsonNode.Parse(e.Content.ToJsonString())!); + } + + return results; + } + + /// Same rule as GET /query: use when it is 1–1000; otherwise 100. + 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); + + private static Dictionary ParseFilters(string? filterPart) + { + var d = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(filterPart)) + return d; + + foreach (var part in filterPart.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + var p = part.Trim(); + var eq = p.IndexOf('='); + if (eq <= 0) + continue; + var k = p[..eq].Trim(); + var v = p[(eq + 1)..].Trim().Trim('"').Trim('\''); + d[k] = v; + } + + return d; + } + + private static bool MatchFilters(JsonObject content, IReadOnlyDictionary filters) + { + foreach (var (k, v) in filters) + { + if (!content.TryGetPropertyValue(k, out var node)) + return false; + var ev = JsonLeaf(node); + if (v == "*") + { + if (string.IsNullOrEmpty(ev)) + return false; + } + else if (ev != v) + { + return false; + } + } + + return true; + } + + private static string JsonLeaf(JsonNode? node) + { + return node switch + { + JsonValue jv when jv.TryGetValue(out var s) => s, + JsonValue jv when jv.TryGetValue(out var b) => b ? "true" : "false", + JsonValue jv when jv.TryGetValue(out var i) => i.ToString(CultureInfo.InvariantCulture), + JsonValue jv when jv.TryGetValue(out var l) => l.ToString(CultureInfo.InvariantCulture), + JsonValue jv when jv.TryGetValue(out var d) => d.ToString("G", CultureInfo.InvariantCulture), + null => "", + _ => node.ToJsonString().Trim('"') + }; + } +} diff --git a/Gts.Store/GtsQueryExecutionResult.cs b/Gts.Store/GtsQueryExecutionResult.cs new file mode 100644 index 0000000..ff09833 --- /dev/null +++ b/Gts.Store/GtsQueryExecutionResult.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// Outcome of (GTS query language, OP#10). +public sealed record GtsQueryExecutionResult(int Limit, string? Error, IReadOnlyList Results) +{ + /// True when is null. + public bool Ok => Error is null; + + /// Failed parse or validation. + public static GtsQueryExecutionResult Failed(int limit, string error) => new(limit, error, Array.Empty()); + + /// Successful query with matching entity bodies (up to ). + public static GtsQueryExecutionResult Success(int limit, IReadOnlyList results) => new(limit, null, results); +} diff --git a/Gts.Store/GtsRegistry.cs b/Gts.Store/GtsRegistry.cs new file mode 100644 index 0000000..33b9210 --- /dev/null +++ b/Gts.Store/GtsRegistry.cs @@ -0,0 +1,659 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store.InMemory; +using Gts.Store.Validation; + +namespace Gts.Store; + +/// Registry for GTS JSON entities with configurable storage and validation. +public abstract class GtsRegistry +{ + private readonly IGtsStore _store; + + /// Registry configuration (e.g. reference validation). + public GtsRegistryConfig Config { get; } + + /// Initializes the registry with the given store and config. + protected GtsRegistry(IGtsStore store, GtsRegistryConfig config) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(config); + + _store = store; + Config = config; + } + + /// Stores or overwrites the entity in the registry. + public ValueTask SaveAsync(GtsJsonEntity entity) + { + // TODO: validation logic + return _store.SaveAsync(entity); + } + + /// Retrieves an entity by GTS ID, or null if not found. + public ValueTask GetAsync(GtsId id) + { + return _store.GetAsync(id); + } + + /// + /// Looks up an instance by GTS instance id or by an opaque id (e.g. UUID for anonymous instances). + /// + public ValueTask GetByInstanceIdAsync(string instanceId) + { + return _store.GetByInstanceIdAsync(instanceId); + } + + /// Returns all entities in the registry. + public ValueTask> GetAllAsync() + { + return _store.GetAllAsync(); + } + + /// Returns the number of entities in the registry. + public ValueTask CountAsync() + { + return _store.CountAsync(); + } + + /// + /// Executes a GTS query expression over stored entities: exact or wildcard id pattern, optional JSON attribute filters (AND). + /// Limit is accepted when in the range 1–1000; otherwise 100 is used (same as GET /query). + /// + /// Query string, e.g. gts.vendor.pkg.* or gts.vendor.pkg.ns.type.v1~x._.inst.v1.0[status=active]. + /// Maximum number of matching entity bodies to return. + /// Cancellation token. + public async ValueTask QueryAsync( + string expr, + int limit = 100, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var lim = GtsQuery.NormalizeLimit(limit); + 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); + cancellationToken.ThrowIfCancellationRequested(); + var results = GtsQuery.Execute(all, parsed, lim, cancellationToken); + return GtsQueryExecutionResult.Success(lim, results); + } + + /// + /// Resolves a single value from a stored entity using the attribute selector: <instance id>@json.path + /// (see GTS spec, attribute selector). must contain @; path uses dot notation; / is equivalent to .. + /// The instance is loaded by (GTS instance id or opaque id). + /// + /// For example gts.vendor.pkg.ns.type.v1~x._.inst.v1.0@payload.orderId. + public async ValueTask GetAttributeAsync( + string gtsWithPath, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var (idPart, pathPart) = GtsAttributeSelector.SplitGtsWithPath(gtsWithPath); + if (pathPart is null) + { + return new GtsAttributeAccessResult( + false, + null, + idPart, + null, + "Attribute selector requires '@path' in the identifier.", + null); + } + + if (string.IsNullOrWhiteSpace(idPart)) + { + return new GtsAttributeAccessResult( + false, + null, + null, + pathPart, + "Missing GTS identifier before '@'.", + null); + } + + var trimmedId = idPart.Trim(); + var entity = await _store.GetByInstanceIdAsync(trimmedId).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (entity is null) + { + return new GtsAttributeAccessResult( + false, + null, + trimmedId, + pathPart, + "Entity not found.", + null); + } + + if (!GtsAttributeSelector.TryResolvePath(entity.Content, pathPart, out var val, out var err, out var fields)) + { + return new GtsAttributeAccessResult( + false, + null, + trimmedId, + pathPart, + err, + fields); + } + + return new GtsAttributeAccessResult(true, val, trimmedId, pathPart, null, null); + } + + /// + /// Validates a stored instance against the JSON Schema for its resolved type (rightmost type in the id chain, or type for anonymous instances). + /// + /// GTS instance id or opaque id (e.g. UUID). + /// Cancellation token. + public async ValueTask ValidateInstanceAsync( + string instanceId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(instanceId)) + return new GtsInstanceValidationResult { Ok = false, Id = instanceId }; + + var trimmed = instanceId.Trim(); + var entity = await _store.GetByInstanceIdAsync(trimmed).ConfigureAwait(false); + if (entity is null) + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "InstanceNotFound" }; + + if (entity.IsSchema) + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "NotAnInstance" }; + + var extract = GtsJsonEntity.ExtractId(entity.Content); + var schemaIdStr = extract.SchemaId; + if (string.IsNullOrEmpty(schemaIdStr) || !schemaIdStr.EndsWith('~')) + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "SchemaIdMissing" }; + + if (!GtsId.TryParse(schemaIdStr, out var schemaGtsId) || schemaGtsId is null || !schemaGtsId.IsType) + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "InvalidSchemaId" }; + + var schemaEntity = await _store.GetAsync(schemaGtsId).ConfigureAwait(false); + if (schemaEntity is null || !schemaEntity.IsSchema) + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "SchemaNotFound" }; + + var all = await _store.GetAllAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var normalizedMap = new Dictionary(); + foreach (var e in all) + { + if (!e.IsSchema || e.GtsId is null) + continue; + normalizedMap[e.GtsId] = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(e.Content); + } + + if (!normalizedMap.ContainsKey(schemaGtsId)) + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "SchemaNotFound" }; + + JsonDocument instDoc; + try + { + instDoc = JsonDocument.Parse(entity.Content.ToJsonString()); + } + catch (JsonException) + { + return new GtsInstanceValidationResult { Ok = false, Id = trimmed, FailureReason = "InvalidInstanceJson" }; + } + + using (instDoc) + { + var results = GtsJsonSchemaEvaluator.Evaluate(instDoc.RootElement, schemaGtsId, normalizedMap); + + if (results.IsValid) + return new GtsInstanceValidationResult { Ok = true, Id = trimmed }; + + return new GtsInstanceValidationResult + { + Ok = false, + Id = trimmed, + FailureReason = "SchemaValidationFailed", + SchemaErrors = GtsJsonSchemaEvaluator.FlattenErrors(results) + }; + } + } + + /// + /// Validates a stored schema: $ref must be local (#) or gts://, and the schema must be + /// forward-compatible with each precedent type in its GTS id chain (immediate parent, then grandparent, …). + /// + /// GTS type id of the schema (trailing ~). + /// Cancellation token. + public async 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); + } + + /// + /// Validates a schema document for a GTS type id using stored precedent schemas only (the document itself need not be in the registry). + /// + /// GTS type id this document defines (trailing ~). + /// JSON Schema body (e.g. from extraction). + /// Cancellation token. + public async 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 }; + } + + /// + /// Validates a stored schema by type id (see ). + /// + public ValueTask ValidateSchemaAsync( + GtsId schemaTypeId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(schemaTypeId); + return ValidateSchemaAsync(schemaTypeId.Id, cancellationToken); + } + + /// + /// Loads every stored entity, walks GTS references in each document, and reports references that do not + /// resolve to another stored schema (type id) or instance. Pattern identifiers are ignored. + /// + /// Cancellation token. + public async ValueTask ResolveRelationshipsAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var all = await _store.GetAllAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return GtsRelationshipResolver.Analyze(all); + } + + /// + /// Verifies full compatibility between stored schemas whose GTS type ids differ only in the + /// last segment's minor version: after normalizing Draft-07 and GTS URIs, the schema trees must be identical. + /// + /// Cancellation token. + public async ValueTask CheckMinorVersionCompatibilityAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var all = await _store.GetAllAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return GtsSchemaMinorVersionCompatibility.AnalyzeStoredSchemas(all); + } + + /// + /// Loads two schema entities and compares them for structural minor compatibility and JSON Schema evolution + /// (backward / forward), ordered by last-segment minor version. + /// + /// First schema type id. + /// Second schema type id. + /// Cancellation token. + public async ValueTask CompareMinorVersionSchemasAsync( + GtsId schemaIdA, + GtsId schemaIdB, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(schemaIdA); + ArgumentNullException.ThrowIfNull(schemaIdB); + + cancellationToken.ThrowIfCancellationRequested(); + var a = await GetAsync(schemaIdA).ConfigureAwait(false); + var b = await GetAsync(schemaIdB).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (a is null || b is null || !a.IsSchema || !b.IsSchema) + { + return new GtsMinorVersionPairComparison + { + AreMinorVariantPair = false, + IsStructurallyCompatible = false, + StructuralIncompatibilityReason = "One or both schemas are missing or not schema documents.", + IsBackwardEvolutionCompatible = false, + BackwardEvolutionErrors = new[] { "Evolution checks require two stored schema entities." }, + IsForwardEvolutionCompatible = false, + ForwardEvolutionErrors = new[] { "Evolution checks require two stored schema entities." } + }; + } + + return GtsSchemaMinorVersionCompatibility.ComparePair(schemaIdA, a.Content, schemaIdB, b.Content); + } + + /// + /// Transforms a stored instance toward a target type id that is a minor variant of the instance's + /// schema (same GTS type family). Fills defaults, updates GTS id const fields, prunes when + /// additionalProperties is false, then validates against the target schema with GTS const tolerance. + /// + /// GTS instance id or opaque id (e.g. UUID). + /// Target schema type id (trailing ~). + /// Cancellation token. + public async 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) + }; + } + } + + 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). + public static GtsRegistry InMemory(GtsRegistryConfig config) + { + return InMemoryGtsRegistry.Simple(config); + } + + /// Creates an in-memory registry with thread-safe storage. + public static GtsRegistry InMemoryThreadSafe(GtsRegistryConfig config) + { + return InMemoryGtsRegistry.Concurrent(config); + } +} diff --git a/Gts.Store/GtsRegistryConfig.cs b/Gts.Store/GtsRegistryConfig.cs new file mode 100644 index 0000000..cf346d5 --- /dev/null +++ b/Gts.Store/GtsRegistryConfig.cs @@ -0,0 +1,7 @@ +namespace Gts.Store; + +/// Configuration for a GTS registry. +/// When true, validate GTS references on save. +public record GtsRegistryConfig( + bool ValidateGtsReferences +); diff --git a/Gts.Store/GtsRelationshipResolutionResult.cs b/Gts.Store/GtsRelationshipResolutionResult.cs new file mode 100644 index 0000000..7ebae57 --- /dev/null +++ b/Gts.Store/GtsRelationshipResolutionResult.cs @@ -0,0 +1,22 @@ +namespace Gts.Store; + +/// +/// Outcome of loading all registry entities and checking cross-references between schemas and instances. +/// +public sealed class GtsRelationshipResolutionResult +{ + /// Total entities returned by the store (schemas and instances, including anonymous instances). + public required int EntityCount { get; init; } + + /// Entities classified as JSON Schemas (). + public required int SchemaCount { get; init; } + + /// Entities that are not JSON Schemas. + public required int InstanceCount { get; init; } + + /// References that do not resolve to a stored schema or instance. + public required IReadOnlyList BrokenReferences { get; init; } + + /// True when there are no broken references. + public bool IsConsistent => BrokenReferences.Count == 0; +} diff --git a/Gts.Store/GtsRelationshipResolver.cs b/Gts.Store/GtsRelationshipResolver.cs new file mode 100644 index 0000000..7366921 --- /dev/null +++ b/Gts.Store/GtsRelationshipResolver.cs @@ -0,0 +1,139 @@ +using Gts.Extraction; + +namespace Gts.Store; + +/// +/// Loads a snapshot of entities and checks that every concrete GTS reference points at a stored schema (type id) or instance. +/// +public static class GtsRelationshipResolver +{ + /// + /// Analyzes for missing schema and instance targets. Pattern identifiers (wildcards) are ignored. + /// + public static GtsRelationshipResolutionResult Analyze(IList entities) + { + ArgumentNullException.ThrowIfNull(entities); + + var schemasById = new Dictionary(); + var instanceKeys = new HashSet(StringComparer.Ordinal); + + foreach (var e in entities) + { + if (e.IsSchema && e.GtsId is not null) + schemasById[e.GtsId] = e; + + var ex = GtsJsonEntity.ExtractId(e.Content); + if (!string.IsNullOrEmpty(ex.Id)) + instanceKeys.Add(ex.Id); + } + + var schemaCount = entities.Count(e => e.IsSchema); + var instanceCount = entities.Count - schemaCount; + + var broken = new List(); + + foreach (var entity in entities) + { + var sourceId = ResolveSourceId(entity); + foreach (var r in entity.GtsRefs) + { + if (IsPatternReference(r.Id)) + continue; + + if (!GtsId.TryParse(r.Id, out var target) || target is null) + { + if (GtsId.TryParsePattern(r.Id, out var pat) && pat is { IsPattern: true }) + continue; + broken.Add(new GtsBrokenReference( + sourceId, + entity.IsSchema, + r.Id, + r.SourcePath, + "InvalidGtsId")); + continue; + } + + if (target.IsType) + { + if (!schemasById.ContainsKey(target)) + { + broken.Add(new GtsBrokenReference( + sourceId, + entity.IsSchema, + r.Id, + r.SourcePath, + "MissingSchema")); + } + } + else + { + if (!instanceKeys.Contains(target.Id)) + { + broken.Add(new GtsBrokenReference( + sourceId, + entity.IsSchema, + r.Id, + r.SourcePath, + "MissingInstance")); + } + } + } + } + + foreach (var entity in entities) + { + if (entity.IsSchema) + continue; + + var ex = GtsJsonEntity.ExtractId(entity.Content); + if (string.IsNullOrEmpty(ex.SchemaId) || !ex.SchemaId.EndsWith("~", StringComparison.Ordinal)) + continue; + + if (!GtsId.TryParse(ex.SchemaId, out var schemaGtsId) || schemaGtsId is null || !schemaGtsId.IsType) + continue; + + if (schemasById.ContainsKey(schemaGtsId)) + continue; + + if (entity.GtsRefs.Any(r => string.Equals(r.Id, ex.SchemaId, StringComparison.Ordinal))) + continue; + + var sourceId = ResolveSourceId(entity); + broken.Add(new GtsBrokenReference( + sourceId, + false, + ex.SchemaId, + "(type binding)", + "MissingTypeBinding")); + } + + return new GtsRelationshipResolutionResult + { + EntityCount = entities.Count, + SchemaCount = schemaCount, + InstanceCount = instanceCount, + BrokenReferences = broken + }; + } + + private static string ResolveSourceId(GtsJsonEntity entity) + { + if (entity.GtsId is not null) + return entity.GtsId.Id; + + var ex = GtsJsonEntity.ExtractId(entity.Content); + return string.IsNullOrEmpty(ex.Id) ? "" : ex.Id; + } + + private static bool IsPatternReference(string refId) + { + // Concrete type/instance strings may also parse as "patterns" in the grammar; still validate them. + if (GtsId.TryParse(refId, out _)) + return false; + + if (refId.Contains('*', StringComparison.Ordinal)) + return true; + + return GtsId.TryParsePattern(refId, out var p) && p is { IsPattern: true }; + } +} diff --git a/Gts.Store/GtsSchemaDerivationValidator.cs b/Gts.Store/GtsSchemaDerivationValidator.cs new file mode 100644 index 0000000..bc2da95 --- /dev/null +++ b/Gts.Store/GtsSchemaDerivationValidator.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store; + +/// Validates that a derived JSON Schema remains forward-compatible with each precedent (parent type) in the GTS type id chain. +public static class GtsSchemaDerivationValidator +{ + /// Walks the type-id chain toward the base and checks forward compatibility against each precedent schema for each hop. + public static (bool Ok, IReadOnlyList Errors) ValidateAgainstRegistry( + GtsId derivedId, + JsonObject derivedSchema, + Func tryLoadTypeSchema) + { + var errors = new List(); + var currentId = derivedId.Id; + var currentSchema = derivedSchema; + + while (true) + { + var parentIdStr = GetParentTypeId(currentId); + if (parentIdStr is null) + break; + + if (!GtsId.TryParse(parentIdStr, out var parentId) || parentId is null) + { + errors.Add($"Invalid precedent type id '{parentIdStr}'."); + break; + } + + var parentSchema = tryLoadTypeSchema(parentId); + if (parentSchema is null) + { + errors.Add($"Precedent schema '{parentIdStr}' not found."); + break; + } + + var parentFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(parentSchema); + var childFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(currentSchema); + + var (ok, hopErrors) = GtsJsonSchemaEvolutionCompatibility.CheckForward(parentFlat, childFlat); + if (!ok) + errors.AddRange(hopErrors); + + currentId = parentIdStr; + currentSchema = parentSchema; + } + + return (errors.Count == 0, errors); + } + + /// Strips the last ~segment from a chained type id, or returns null for a single-segment base. + public static string? GetParentTypeId(string schemaTypeId) + { + if (string.IsNullOrEmpty(schemaTypeId) || !schemaTypeId.EndsWith("~", StringComparison.Ordinal)) + return null; + + var withoutTrailing = schemaTypeId.AsSpan(0, schemaTypeId.Length - 1); + var last = withoutTrailing.LastIndexOf('~'); + if (last < 0) + return null; + + return withoutTrailing[..(last + 1)].ToString(); + } +} diff --git a/Gts.Store/GtsSchemaMinorVersionCompatibility.cs b/Gts.Store/GtsSchemaMinorVersionCompatibility.cs new file mode 100644 index 0000000..a12a72a --- /dev/null +++ b/Gts.Store/GtsSchemaMinorVersionCompatibility.cs @@ -0,0 +1,274 @@ +using System.Linq; +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store.Validation; + +namespace Gts.Store; + +/// +/// Compares JSON Schema documents for two GTS type ids that differ only in the minor version +/// of the last segment. Full compatibility means the normalized schemas are +/// structurally identical (same validation behavior modulo minor identity in $id / $ref). +/// +public static class GtsSchemaMinorVersionCompatibility +{ + /// + /// Compares two schema documents for full (bidirectional) minor-version compatibility. + /// + public static GtsMinorVersionCompatibilityResult CompareSchemas( + GtsId idA, + JsonObject schemaA, + GtsId idB, + JsonObject schemaB) + { + ArgumentNullException.ThrowIfNull(idA); + ArgumentNullException.ThrowIfNull(idB); + ArgumentNullException.ThrowIfNull(schemaA); + ArgumentNullException.ThrowIfNull(schemaB); + + if (!idA.IsType || !idB.IsType) + { + return new GtsMinorVersionCompatibilityResult + { + AreCompatible = false, + Reason = "Both identifiers must be GTS type ids (trailing ~)." + }; + } + + if (!GtsTypeFamily.AreSameLogicalTypeMinorVariants(idA, idB)) + { + return new GtsMinorVersionCompatibilityResult + { + AreCompatible = false, + Reason = + "Type identifiers must match in every segment except the last, and both must specify a minor version on the last segment." + }; + } + + var canonA = GtsSchemaMinorVersionCanonicalizer.PrepareForComparison(schemaA); + var canonB = GtsSchemaMinorVersionCanonicalizer.PrepareForComparison(schemaB); + + if (JsonNode.DeepEquals(canonA, canonB)) + return new GtsMinorVersionCompatibilityResult { AreCompatible = true }; + + return new GtsMinorVersionCompatibilityResult + { + AreCompatible = false, + Reason = + "Schemas differ after normalizing minor versions in identifiers (not fully compatible under the same validation semantics)." + }; + } + + /// + /// Compares two schema documents for structural (normalized identity) compatibility and for JSON Schema + /// evolution rules: backward (old instances remain valid under the newer schema) and forward + /// (newer instances remain valid under the older schema), using the lower minor as "old" and the higher as "new". + /// + public static GtsMinorVersionPairComparison ComparePair( + GtsId idA, + JsonObject schemaA, + GtsId idB, + JsonObject schemaB) + { + ArgumentNullException.ThrowIfNull(idA); + ArgumentNullException.ThrowIfNull(idB); + ArgumentNullException.ThrowIfNull(schemaA); + ArgumentNullException.ThrowIfNull(schemaB); + + var structural = CompareSchemas(idA, schemaA, idB, schemaB); + + if (!idA.IsType || !idB.IsType || !GtsTypeFamily.AreSameLogicalTypeMinorVariants(idA, idB)) + { + return new GtsMinorVersionPairComparison + { + AreMinorVariantPair = false, + IsStructurallyCompatible = structural.AreCompatible, + StructuralIncompatibilityReason = structural.Reason, + IsBackwardEvolutionCompatible = false, + BackwardEvolutionErrors = new[] { "Not a minor-variant type pair; evolution checks were not run." }, + IsForwardEvolutionCompatible = false, + ForwardEvolutionErrors = new[] { "Not a minor-variant type pair; evolution checks were not run." } + }; + } + + OrderByLastMinor(idA, schemaA, idB, schemaB, out var olderId, out var olderSchema, out var newerId, out var newerSchema); + + var oldFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(olderSchema); + var newFlat = GtsJsonSchemaEvolutionCompatibility.FlattenSchema(newerSchema); + var (backOk, backErr) = GtsJsonSchemaEvolutionCompatibility.CheckBackward(oldFlat, newFlat); + var (fwdOk, fwdErr) = GtsJsonSchemaEvolutionCompatibility.CheckForward(oldFlat, newFlat); + + return new GtsMinorVersionPairComparison + { + AreMinorVariantPair = true, + OlderSchemaId = olderId, + NewerSchemaId = newerId, + IsStructurallyCompatible = structural.AreCompatible, + StructuralIncompatibilityReason = structural.Reason, + IsBackwardEvolutionCompatible = backOk, + BackwardEvolutionErrors = backErr, + IsForwardEvolutionCompatible = fwdOk, + ForwardEvolutionErrors = fwdErr + }; + } + + private static void OrderByLastMinor( + GtsId idA, + JsonObject schemaA, + GtsId idB, + JsonObject schemaB, + out GtsId olderId, + out JsonObject olderSchema, + out GtsId newerId, + out JsonObject newerSchema) + { + var aLast = idA.Segments.Last(); + var bLast = idB.Segments.Last(); + var am = aLast.VersionMinor!.Value; + var bm = bLast.VersionMinor!.Value; + if (am <= bm) + { + olderId = idA; + olderSchema = schemaA; + newerId = idB; + newerSchema = schemaB; + } + else + { + olderId = idB; + olderSchema = schemaB; + newerId = idA; + newerSchema = schemaA; + } + } + + /// + /// Checks every pair of stored schemas that belong to the same minor-evolution family. + /// Pairs that are not minor variants (e.g. v1~ vs v1.0~) are skipped. + /// + public static GtsMinorVersionCompatibilityReport AnalyzeStoredSchemas(IEnumerable entities) + { + ArgumentNullException.ThrowIfNull(entities); + + var schemas = entities + .Where(e => e.IsSchema && e.GtsId is not null) + .ToList(); + + var issues = new List(); + var groups = schemas.GroupBy(e => GtsTypeFamily.GetMinorEvolutionFamilyKey(e.GtsId!)); + + foreach (var group in groups) + { + var members = group.ToList(); + if (members.Count < 2) + continue; + + for (var i = 0; i < members.Count; i++) + { + for (var j = i + 1; j < members.Count; j++) + { + var a = members[i]; + var b = members[j]; + var idA = a.GtsId!; + var idB = b.GtsId!; + + if (string.Equals(idA.Id, idB.Id, StringComparison.Ordinal)) + continue; + + if (!GtsTypeFamily.AreSameLogicalTypeMinorVariants(idA, idB)) + continue; + + var result = CompareSchemas(idA, a.Content, idB, b.Content); + if (!result.AreCompatible) + { + issues.Add(new GtsMinorVersionPairIssue + { + SchemaIdA = idA, + SchemaIdB = idB, + Reason = result.Reason ?? "Incompatible." + }); + } + } + } + } + + return new GtsMinorVersionCompatibilityReport + { + SchemaCount = schemas.Count, + IncompatiblePairs = issues + }; + } +} + +/// Outcome of comparing two minor-version schema documents. +public sealed class GtsMinorVersionCompatibilityResult +{ + /// True when the two schemas are fully compatible (structurally equal after normalization). + public required bool AreCompatible { get; init; } + + /// Human-readable explanation when is false. + public string? Reason { get; init; } +} + +/// Result of scanning many stored schemas for minor-version compatibility. +public sealed class GtsMinorVersionCompatibilityReport +{ + /// Number of schema entities considered. + public required int SchemaCount { get; init; } + + /// Schema pairs that are minor variants but not fully compatible. + public required IReadOnlyList IncompatiblePairs { get; init; } + + /// True when there are no incompatible minor-variant pairs. + public bool AreAllCompatible => IncompatiblePairs.Count == 0; +} + +/// One incompatible minor-variant pair. +public sealed class GtsMinorVersionPairIssue +{ + /// First schema id. + public required GtsId SchemaIdA { get; init; } + + /// Second schema id. + public required GtsId SchemaIdB { get; init; } + + /// Why the pair failed full compatibility. + public required string Reason { get; init; } +} + +/// +/// Result of comparing two schemas for structural minor compatibility and optional evolution (backward / forward) rules. +/// +public sealed class GtsMinorVersionPairComparison +{ + /// True when both ids are type ids in the same minor-evolution family. + public required bool AreMinorVariantPair { get; init; } + + /// When is true, the lower-minor schema id (evolution "old"). + public GtsId? OlderSchemaId { get; init; } + + /// When is true, the higher-minor schema id (evolution "new"). + public GtsId? NewerSchemaId { get; init; } + + /// True when normalized schema trees match (strictest notion of minor compatibility). + public required bool IsStructurallyCompatible { get; init; } + + /// When is false, a short explanation. + public string? StructuralIncompatibilityReason { get; init; } + + /// Backward evolution: data valid under the older schema validates against the newer schema. + public required bool IsBackwardEvolutionCompatible { get; init; } + + /// Messages from backward evolution analysis. + public required IReadOnlyList BackwardEvolutionErrors { get; init; } + + /// Forward evolution: data valid under the newer schema validates against the older schema. + public required bool IsForwardEvolutionCompatible { get; init; } + + /// Messages from forward evolution analysis. + public required IReadOnlyList ForwardEvolutionErrors { get; init; } + + /// True when both evolution directions pass (weaker than ). + public bool IsEvolutionFullyCompatible => + IsBackwardEvolutionCompatible && IsForwardEvolutionCompatible; +} diff --git a/Gts.Store/GtsSchemaRefFormatValidator.cs b/Gts.Store/GtsSchemaRefFormatValidator.cs new file mode 100644 index 0000000..a855a4a --- /dev/null +++ b/Gts.Store/GtsSchemaRefFormatValidator.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; + +namespace Gts.Store; + +/// Validates $ref shape in JSON Schema documents (local # or gts:// only). +public static class GtsSchemaRefFormatValidator +{ + /// Throws when an invalid $ref is found. + public static void ValidateRefs(JsonNode? node, string path = "") + { + switch (node) + { + case JsonObject obj: + if (obj.TryGetPropertyValue("$ref", out var r) && r is JsonValue rv && rv.TryGetValue(out var refUri)) + { + var currentPath = string.IsNullOrEmpty(path) ? "$ref" : path + ".$ref"; + if (refUri.StartsWith("#", StringComparison.Ordinal)) + { + // local ref OK + } + else if (refUri.StartsWith("gts://", StringComparison.Ordinal)) + { + var gtsId = refUri["gts://".Length..]; + if (!GtsId.TryParse(gtsId, out _) && !GtsId.TryParsePattern(gtsId, out _)) + throw new InvalidOperationException( + $"Invalid $ref at '{currentPath}': '{refUri}' contains invalid GTS identifier '{gtsId}'."); + } + else + throw new InvalidOperationException( + $"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) + { + if (k == "$ref") + continue; + var nested = string.IsNullOrEmpty(path) ? k : path + "." + k; + ValidateRefs(v, nested); + } + + break; + case JsonArray arr: + for (var i = 0; i < arr.Count; i++) + ValidateRefs(arr[i], $"{path}[{i}]"); + break; + } + } +} diff --git a/Gts.Store/GtsSchemaValidationResult.cs b/Gts.Store/GtsSchemaValidationResult.cs new file mode 100644 index 0000000..c35022d --- /dev/null +++ b/Gts.Store/GtsSchemaValidationResult.cs @@ -0,0 +1,21 @@ +namespace Gts.Store; + +/// +/// Outcome of validating a stored JSON Schema against the GTS type-chain precedent (parent type) and ref rules (OP#12). +/// +public sealed class GtsSchemaValidationResult +{ + /// True when the schema exists, is a schema document, and is forward-compatible with every precedent in its type id chain. + public bool Ok { get; init; } + + /// The GTS type id of the schema that was validated (trailing ~). + public string? SchemaId { get; init; } + + /// High-level failure code when is false; null on success. + public string? FailureReason { get; init; } + + /// + /// Detailed messages: JSON Schema evolution violations vs a precedent, or a single ref-format error. + /// + public IReadOnlyList? Errors { get; init; } +} diff --git a/Gts.Store/GtsTypeFamily.cs b/Gts.Store/GtsTypeFamily.cs new file mode 100644 index 0000000..5d16875 --- /dev/null +++ b/Gts.Store/GtsTypeFamily.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; + +namespace Gts.Store; + +/// +/// Helpers for grouping GTS type identifiers that differ only in the minor component +/// of the last segment (same vendor, package, namespace, type, and major version). +/// +public static class GtsTypeFamily +{ + private static readonly Regex LastMinorSuffix = new( + @"\.v(\d+)\.(\d+)~$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + /// + /// Strips the minor version from the last .vMAJOR.MINOR~ suffix of a GTS type id. + /// Chained ids only the final segment is affected (e.g. ...v1.0~...v2.3~...v1.0~...v2~). + /// + public static string StripLastMinorFromTypeId(string typeId) + { + if (string.IsNullOrEmpty(typeId)) + return typeId; + return LastMinorSuffix.Replace(typeId, ".v$1~"); + } + + /// + /// Returns a key shared by all type ids in the same minor-evolution family (last-segment minor ignored). + /// + public static string GetMinorEvolutionFamilyKey(GtsId id) + { + ArgumentNullException.ThrowIfNull(id); + if (!id.IsType) + throw new ArgumentException("Expected a type identifier (trailing ~).", nameof(id)); + return StripLastMinorFromTypeId(id.Id); + } + + /// + /// True if both ids are type ids with identical segment chains except the last segment's minor, + /// which must be present on both sides (evolution v1.0v1.1). + /// + public static bool AreSameLogicalTypeMinorVariants(GtsId a, GtsId b) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(b); + if (!a.IsType || !b.IsType) + return false; + if (a.Segments.Count != b.Segments.Count) + return false; + + var aSegs = a.Segments as IList ?? a.Segments.ToList(); + var bSegs = b.Segments as IList ?? b.Segments.ToList(); + + for (var i = 0; i < aSegs.Count; i++) + { + var x = aSegs[i]; + var y = bSegs[i]; + var last = i == aSegs.Count - 1; + if (!SegmentEqualsForMinorVariantPair(x, y, last)) + return false; + } + + return true; + } + + private static bool SegmentEqualsForMinorVariantPair(GtsIdSegment x, GtsIdSegment y, bool isLast) + { + if (x.Vendor != y.Vendor) return false; + if (x.Package != y.Package) return false; + if (x.Namespace != y.Namespace) return false; + if (x.Type != y.Type) return false; + if (x.VersionMajor != y.VersionMajor) return false; + + if (!isLast) + return x.VersionMinor == y.VersionMinor; + + // Last segment: both must carry an explicit minor (MINOR evolution); values may match (identity). + return x.VersionMinor is not null + && y.VersionMinor is not null; + } +} diff --git a/Gts.Store/IGtsStore.cs b/Gts.Store/IGtsStore.cs new file mode 100644 index 0000000..dfd8aed --- /dev/null +++ b/Gts.Store/IGtsStore.cs @@ -0,0 +1,26 @@ +using Gts.Extraction; + +namespace Gts.Store; + +/// 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); + + /// Retrieves an entity by GTS ID, or null if not found. + ValueTask GetAsync(GtsId id); + + /// + /// Looks up an instance by GTS instance id or by an opaque id (e.g. UUID for anonymous instances). + /// + ValueTask GetByInstanceIdAsync(string instanceId); + + /// Returns all stored entities, including instances without a (e.g. opaque ids). + ValueTask> GetAllAsync(); + + /// Returns the number of stored entities. + ValueTask CountAsync(); + + //ValueTask ValidateAsync(); // TODO: +} diff --git a/Gts.Store/InMemory/InMemoryGtsRegistry.cs b/Gts.Store/InMemory/InMemoryGtsRegistry.cs new file mode 100644 index 0000000..84b771d --- /dev/null +++ b/Gts.Store/InMemory/InMemoryGtsRegistry.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; +using Gts.Extraction; + +namespace Gts.Store.InMemory; + +/// In-memory implementation of . +internal 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) + { + 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); + } +} diff --git a/Gts.Store/InMemory/InMemoryGtsStore.cs b/Gts.Store/InMemory/InMemoryGtsStore.cs new file mode 100644 index 0000000..10ae11f --- /dev/null +++ b/Gts.Store/InMemory/InMemoryGtsStore.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +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() +{ + private readonly T _entities = new(); + private readonly ConcurrentDictionary _instanceKeys = new(StringComparer.Ordinal); + + /// + public ValueTask SaveAsync(GtsJsonEntity entity) + { + ArgumentNullException.ThrowIfNull(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)); + + if (entity.GtsId is not null) + _entities[entity.GtsId] = entity; + + _instanceKeys[extract.Id] = entity; + + return ValueTask.CompletedTask; + } + + /// + public ValueTask GetAsync(GtsId id) + { + _entities.TryGetValue(id, out var entity); + return ValueTask.FromResult(entity); + } + + /// + public ValueTask GetByInstanceIdAsync(string instanceId) + { + if (string.IsNullOrWhiteSpace(instanceId)) + return ValueTask.FromResult(null); + + var trimmed = instanceId.Trim(); + if (GtsId.TryParse(trimmed, out var gid) && gid is not null && _entities.TryGetValue(gid, out var byGts)) + return ValueTask.FromResult(byGts); + + _instanceKeys.TryGetValue(trimmed, out var byKey); + return ValueTask.FromResult(byKey); + } + + /// + public ValueTask> GetAllAsync() + { + 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() + { + var seen = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var e in _instanceKeys.Values) + seen.Add(e); + return ValueTask.FromResult(seen.Count); + } +} diff --git a/Gts.Store/Properties/AssemblyInfo.cs b/Gts.Store/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f22a328 --- /dev/null +++ b/Gts.Store/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Gts.Server")] diff --git a/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs b/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs new file mode 100644 index 0000000..13f8704 --- /dev/null +++ b/Gts.Store/Validation/GtsJsonSchemaEvaluator.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; + +namespace Gts.Store.Validation; + +internal static class GtsJsonSchemaEvaluator +{ + internal static EvaluationResults Evaluate( + JsonElement 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; + } + + 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}"); + } + + foreach (var d in node.Details ?? []) + Walk(d, sink); + } +} diff --git a/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs new file mode 100644 index 0000000..797fdb7 --- /dev/null +++ b/Gts.Store/Validation/GtsSchemaDocumentNormalizer.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Nodes; + +namespace Gts.Store.Validation; + +/// +/// Prepares GTS JSON Schema documents for Draft 7 evaluation: renames $$ keywords and +/// rewrites gts:// in $id / $ref to synthetic HTTPS URIs resolvable by the evaluator. +/// +internal static class GtsSchemaDocumentNormalizer +{ + private const string GtsUriPrefix = "gts://"; + + internal static JsonObject ForJsonSchemaEvaluation(JsonObject root) + { + var clone = JsonNode.Parse(root.ToJsonString())!.AsObject(); + RenameDoubleDollarKeysDeep(clone); + RewriteGtsUrisDeep(clone); + return clone; + } + + 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 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; + } + } + } + + 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) + { + var s = jv.GetValue(); + if (!string.IsNullOrEmpty(s)) + { + 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; + } + } + } + } + + foreach (var p in obj) + RewriteGtsUrisDeep(p.Value); + break; + } + case JsonArray arr: + { + foreach (var item in arr) + RewriteGtsUrisDeep(item); + break; + } + } + } +} diff --git a/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs b/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs new file mode 100644 index 0000000..b38e13c --- /dev/null +++ b/Gts.Store/Validation/GtsSchemaMinorVersionCanonicalizer.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Nodes; +using Gts.Store; + +namespace Gts.Store.Validation; + +/// +/// Normalizes GTS JSON Schema documents so two minor versions of the same type can be compared +/// for structural equality (full compatibility): Draft-7 prep, then stable URIs with last minor stripped. +/// +internal static class GtsSchemaMinorVersionCanonicalizer +{ + internal static JsonObject PrepareForComparison(JsonObject root) + { + var normalized = GtsSchemaDocumentNormalizer.ForJsonSchemaEvaluation(root); + var clone = JsonNode.Parse(normalized.ToJsonString())!.AsObject(); + RewriteGtsIdentifiersDeep(clone); + return clone; + } + + 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)) + { + if (TryCanonicalizeUriString(s.Trim(), out var rewritten)) + obj[key] = rewritten; + } + else + RewriteGtsIdentifiersDeep(val); + } + + break; + } + case JsonArray arr: + { + foreach (var item in arr) + RewriteGtsIdentifiersDeep(item); + break; + } + } + } + + private static bool TryCanonicalizeUriString(string s, out JsonValue rewritten) + { + if (s.StartsWith(GtsSchemaResolutionUris.SyntheticBase, StringComparison.Ordinal)) + { + var encoded = s.AsSpan(GtsSchemaResolutionUris.SyntheticBase.Length); + if (encoded.IsEmpty) + { + rewritten = JsonValue.Create(s)!; + return false; + } + + var gtsId = Uri.UnescapeDataString(encoded.ToString()); + if (gtsId.Length == 0) + { + rewritten = JsonValue.Create(s)!; + return false; + } + + var stripped = GtsTypeFamily.StripLastMinorFromTypeId(gtsId); + if (stripped == gtsId) + { + rewritten = JsonValue.Create(s)!; + return false; + } + + rewritten = JsonValue.Create(GtsSchemaResolutionUris.SyntheticBase + Uri.EscapeDataString(stripped))!; + return true; + } + + const string gtsUriScheme = "gts://"; + if (s.StartsWith(gtsUriScheme, StringComparison.Ordinal)) + { + var inner = s[gtsUriScheme.Length..]; + var stripped = GtsTypeFamily.StripLastMinorFromTypeId(inner); + if (stripped == inner) + { + rewritten = JsonValue.Create(s)!; + return false; + } + + rewritten = JsonValue.Create(gtsUriScheme + stripped)!; + return true; + } + + rewritten = JsonValue.Create(s)!; + return false; + } +} diff --git a/Gts.Store/Validation/GtsSchemaResolutionUris.cs b/Gts.Store/Validation/GtsSchemaResolutionUris.cs new file mode 100644 index 0000000..c30fe82 --- /dev/null +++ b/Gts.Store/Validation/GtsSchemaResolutionUris.cs @@ -0,0 +1,34 @@ +namespace Gts.Store.Validation; + +/// +/// Maps GTS schema identifiers to absolute HTTPS URIs for JSON Schema tooling. +/// .NET's rejects gts:// identifiers that contain ~ in the host, +/// so evaluation uses a stable synthetic base and percent-encoded paths. +/// +internal static class GtsSchemaResolutionUris +{ + internal const string SyntheticBase = "https://gts.json-schema.invalid/"; + + internal static Uri ToSyntheticUri(string gtsTypeId) + { + return new Uri(SyntheticBase + Uri.EscapeDataString(gtsTypeId)); + } + + internal static bool TryGetGtsId(Uri uri, out string gtsId) + { + gtsId = ""; + if (!uri.IsAbsoluteUri || uri.Scheme != Uri.UriSchemeHttps) + return false; + + var abs = uri.AbsoluteUri; + if (!abs.StartsWith(SyntheticBase, StringComparison.Ordinal)) + return false; + + var encoded = abs.AsSpan(SyntheticBase.Length); + if (encoded.IsEmpty) + return false; + + gtsId = Uri.UnescapeDataString(encoded.ToString()); + return gtsId.Length > 0; + } +} diff --git a/Gts.Tests/Attribute/GtsAttributeSelectorTests.cs b/Gts.Tests/Attribute/GtsAttributeSelectorTests.cs new file mode 100644 index 0000000..1468c98 --- /dev/null +++ b/Gts.Tests/Attribute/GtsAttributeSelectorTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Attribute; + +public class GtsAttributeSelectorTests +{ + private static readonly JsonObject Sample = JsonNode.Parse(""" + { + "id": "gts.x.msg.v1~x._.m.v1.0", + "foo": { "bar": 42 }, + "items": [ { "a": 1 }, { "a": 2 } ] + } + """)!.AsObject(); + + [Fact] + public void TryResolve_nested_property() + { + Assert.True(GtsAttributeSelector.TryResolve(Sample, "foo.bar", out var v)); + Assert.Equal(42, v!.GetValue()); + } + + [Fact] + public void TryResolve_slash_equals_dot() + { + Assert.True(GtsAttributeSelector.TryResolve(Sample, "foo/bar", out var v)); + Assert.Equal(42, v!.GetValue()); + } + + [Fact] + public void TryResolve_chained_array_indices() + { + Assert.True(GtsAttributeSelector.TryResolve(Sample, "items[0].a", out var v)); + Assert.Equal(1, v!.GetValue()); + } + + [Fact] + public void TryResolve_segment_foo_bracket_0_bracket_1_style() + { + var nested = JsonNode.Parse("""{ "m": [ [ 10, 20 ], [ 30, 40 ] ] }""")!.AsObject(); + Assert.True(GtsAttributeSelector.TryResolve(nested, "m[0][1]", out var v)); + Assert.Equal(20, v!.GetValue()); + } + + [Fact] + public void TryResolve_empty_path_returns_root() + { + Assert.True(GtsAttributeSelector.TryResolve(Sample, "", out var v)); + Assert.Same(Sample, v); + } + + [Fact] + public void TryResolvePath_sets_available_fields_on_missing_key() + { + var ok = GtsAttributeSelector.TryResolvePath(Sample, "notAProperty", out _, out var err, out var fields); + Assert.False(ok); + Assert.NotNull(err); + Assert.NotNull(fields); + Assert.Contains("foo.bar", fields); + Assert.Contains("id", fields); + } + + [Fact] + public async Task GetAttributeAsync_resolves_stored_entity() + { + var reg = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await reg.SaveAsync(GtsJsonEntity.ExtractEntity(Sample)); + + var r = await reg.GetAttributeAsync("gts.x.msg.v1~x._.m.v1.0@foo.bar"); + Assert.True(r.Resolved); + Assert.Equal(42, r.Value!.GetValue()); + } + + [Fact] + public async Task GetAttributeAsync_requires_at() + { + var reg = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var r = await reg.GetAttributeAsync("gts.x.msg.v1~x._.m.v1.0"); + Assert.False(r.Resolved); + Assert.Contains("@path", r.Error ?? "", StringComparison.Ordinal); + } +} diff --git a/Gts.Tests/Extraction/ExtractBasicTests.cs b/Gts.Tests/Extraction/ExtractBasicTests.cs new file mode 100644 index 0000000..428208e --- /dev/null +++ b/Gts.Tests/Extraction/ExtractBasicTests.cs @@ -0,0 +1,123 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; + +namespace Gts.Tests.Extraction; + +public class ExtractBasicTests +{ + [Theory] + [InlineData("$id")] + [InlineData("gtsId")] + [InlineData("gtsIid")] + [InlineData("gtsOid")] + [InlineData("gtsI")] + [InlineData("gts_id")] + [InlineData("gts_oid")] + [InlineData("gts_iid")] + [InlineData("id")] + 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); + } + + [Theory] + [InlineData("invalid_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); + } + + [Theory] + [InlineData("custom_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]}); + + Assert.Equal(id, result.SelectedEntityField); + Assert.Equal(gtsId, result.Id); + } + + [Fact] + public void ExtractingOneOfDefaultIdsReturnsIdsByOrdering() + { + var gtsId = "gts.vendor.package.namespace.type.v0~a.b.c.d.v1"; + var result = GtsJsonEntity.ExtractId( + new JsonObject + { + ["name"] = "Some Name", + ["gtsId"] = gtsId, // 1st + ["$id"] = gtsId, // 2nd + }); + + Assert.Equal(gtsId, result.Id); + Assert.Equal("gtsId", result.SelectedEntityField); // 1st wins + } + + [Fact] + public void ExtractingDollarIdReturnsIdWithStrippedPrefix() + { + var result = GtsJsonEntity.ExtractId( + new JsonObject + { + ["$id"] = "gts://gts.vendor.package.namespace.type.v1.0~", + ["$schema"] = "http://json-schema.org/draft-07/schema#", + ["type"] = "object", + }); + + Assert.Equal("gts.vendor.package.namespace.type.v1.0~", result.Id); + } + + [Fact] + public void ExtractingInvalidIdFallbacksToNextValidId() + { + var result = GtsJsonEntity.ExtractId( + new JsonObject + { + ["gtsId"] = "invalid-id", // invalid + ["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); + } + + [Fact] + public void ExtractingIdReturnsNullWhenValidIdDoesNotExist() + { + var result = GtsJsonEntity.ExtractId( + new JsonObject + { + ["name"] = "Some Name", + }); + + Assert.Null(result.Id); + Assert.Null(result.SelectedEntityField); + } +} \ No newline at end of file diff --git a/Gts.Tests/Extraction/ExtractEntityTests.cs b/Gts.Tests/Extraction/ExtractEntityTests.cs new file mode 100644 index 0000000..c848f80 --- /dev/null +++ b/Gts.Tests/Extraction/ExtractEntityTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; + +namespace Gts.Tests.Extraction; + +public class ExtractEntityTests +{ + [Fact] + public void ExtractingPopulatesRefsWithIds() + { + var entity = GtsJsonEntity.ExtractEntity(new JsonObject + { + ["$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() + { + var entity = GtsJsonEntity.ExtractEntity(new JsonObject + { + ["$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() + { + var entity = GtsJsonEntity.ExtractEntity(new JsonObject + { + ["$id"] = "gts.x.test.core.schema.v1~", + ["properties"] = new JsonObject + { + ["field1"] = new JsonObject + { + ["$ref"] = "gts.x.test.core.field.v1~", + }, + }, + }); + + Assert.Contains(entity.GtsRefs, + r => r is { Id: "gts.x.test.core.field.v1~", SourcePath: "properties.field1.$ref" }); + } + + [Fact] + public void ExtractingPopulatesRefsWithExplicitNestedArrayRefs() + { + var entity = GtsJsonEntity.ExtractEntity(new JsonObject + { + ["$id"] = "gts.x.test.core.schema.v1~", + ["items"] = new JsonArray + { + new JsonObject + { + ["$ref"] = "gts.x.test.core.item.v1~", + } + }, + }); + + Assert.Contains(entity.GtsRefs, + r => r is { Id: "gts.x.test.core.item.v1~", SourcePath: "items[0].$ref" }); + } + + [Fact] + public void ExtractingPopulatesRefsFromDoubleDollarRefInAllOf() + { + var json = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~x.test6.rel.missing_base.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ { "$$ref": "gts://gts.x.test6.events.type.v1~" } ] + } + """; + + var entity = GtsJsonEntity.ExtractEntity(JsonNode.Parse(json)!.AsObject()); + + Assert.Contains(entity.GtsRefs, r => r.Id == "gts.x.test6.events.type.v1~"); + } +} \ No newline at end of file diff --git a/Gts.Tests/Extraction/ExtractSchemaTests.cs b/Gts.Tests/Extraction/ExtractSchemaTests.cs new file mode 100644 index 0000000..8dade53 --- /dev/null +++ b/Gts.Tests/Extraction/ExtractSchemaTests.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; + +namespace Gts.Tests.Extraction; + +public class ExtractSchemaTests +{ + [Fact] + 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); + } + + [Theory] + [InlineData("gtsTid")] + [InlineData("gtsType")] + [InlineData("gtsT")] + [InlineData("gts_t")] + [InlineData("gts_tid")] + [InlineData("gts_type")] + [InlineData("type")] + [InlineData("schema")] + 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); + } + + [Fact] + public void ExtractingDollarSchemaReturnsSchemaFromSpecifiedField() + { + var result = GtsJsonEntity.ExtractId( + new JsonObject + { + ["id"] = "gts.vendor.package.namespace.type.v1.0~", + ["$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); + Assert.Equal("http://json-schema.org/draft-07/schema#", result.SchemaId); + } +} diff --git a/Gts.Tests/Gts.Tests.csproj b/Gts.Tests/Gts.Tests.csproj new file mode 100644 index 0000000..3a0b7e0 --- /dev/null +++ b/Gts.Tests/Gts.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/Gts.Tests/GtsIdSegmentTests.cs b/Gts.Tests/GtsIdSegmentTests.cs new file mode 100644 index 0000000..50f0858 --- /dev/null +++ b/Gts.Tests/GtsIdSegmentTests.cs @@ -0,0 +1,85 @@ +namespace Gts.Tests; + +public class GtsIdSegmentTests +{ + [Fact] + public void ToStringForVendorReturnsPattern() + { + var segment = new GtsIdSegment + { + Vendor = "vendor" + }; + + Assert.Equal("vendor.*", segment.ToString()); + } + + [Fact] + public void ToStringForVendorAndPackageReturnsPattern() + { + var segment = new GtsIdSegment + { + Vendor = "vendor", + Package = "pkg" + }; + + Assert.Equal("vendor.pkg.*", segment.ToString()); + } + + [Fact] + public void ToStringForVendorAndPackageAndNamespaceReturnsPattern() + { + var segment = new GtsIdSegment + { + Vendor = "vendor", + Package = "pkg", + Namespace = "ns" + }; + + Assert.Equal("vendor.pkg.ns.*", segment.ToString()); + } + + [Fact] + public void ToStringForVendorAndPackageAndNamespaceAndTypeReturnsPattern() + { + var segment = new GtsIdSegment + { + Vendor = "vendor", + Package = "pkg", + Namespace = "ns", + Type = "type" + }; + + Assert.Equal("vendor.pkg.ns.type.*", segment.ToString()); + } + + [Fact] + public void ToStringForVendorAndPackageAndNamespaceAndTypeAndMajorReturnsId() + { + var segment = new GtsIdSegment + { + Vendor = "vendor", + Package = "pkg", + Namespace = "ns", + Type = "type", + VersionMajor = 1, + }; + + Assert.Equal("vendor.pkg.ns.type.v1", segment.ToString()); + } + + [Fact] + public void ToStringForVendorAndPackageAndNamespaceAndTypeAndMajorAndMinorReturnsId() + { + var segment = new GtsIdSegment + { + Vendor = "vendor", + Package = "pkg", + Namespace = "ns", + Type = "type", + 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 new file mode 100644 index 0000000..6227a4d --- /dev/null +++ b/Gts.Tests/GtsIdTests.cs @@ -0,0 +1,132 @@ +using Gts.Parsing; +using Gts.Utils; + +namespace Gts.Tests; + +public class GtsIdTests +{ + [Fact] + public void CanBeParsedAsSingleSegmentType() + { + var id = GtsId.Parse("gts.vendor.package.namespace.type.v1.0~"); + + Assert.True(id.IsType); + Assert.Single(id.Segments); + } + + [Fact] + 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); + } + + [Fact] + public void CanBeParsedAsMultipleSegmentsInstance() + { + var id = GtsId.Parse("gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.v1.0"); + + Assert.True(id.IsInstance); + Assert.Equal(2, id.Segments.Count); + } + + [Fact] + public void CanBeParsedAsSingleSegmentPattern() + { + var id = GtsId.ParsePattern("gts.vendor.package.namespace.type.*"); + + Assert.False(id.IsType); + Assert.False(id.IsInstance); + Assert.True(id.IsPattern); + Assert.Single(id.Segments); + } + + [Fact] + 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)); + } + + [Fact] + 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); + } + + [Fact] + public void ToStringReturnsId() + { + var idStr = "gts.vendor.package.namespace.type.v1.0~"; + var id = GtsId.Parse(idStr); + Assert.Equal(idStr, id.ToString()); + Assert.Equal(id.Id, id.ToString()); + } + + [Fact] + public void ToStringReturnsPattern() + { + var patternStr = "gts.vendor.package.namespace.type.*"; + var pattern = GtsId.ParsePattern(patternStr); + Assert.Equal(patternStr, pattern.ToString()); + Assert.Equal(pattern.Id, pattern.ToString()); + } + + [Fact] + 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()); + } + + [Fact] + 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); + } +} \ No newline at end of file diff --git a/Gts.Tests/Matching/ExactMatchingTests.cs b/Gts.Tests/Matching/ExactMatchingTests.cs new file mode 100644 index 0000000..1fbeb34 --- /dev/null +++ b/Gts.Tests/Matching/ExactMatchingTests.cs @@ -0,0 +1,36 @@ +namespace Gts.Tests.Matching; + +public class ExactMatchingTests +{ + [Fact] + public void MatchesSameInstanceId() + { + var id = "gts.vendor.pkg.ns.type.v1.0"; + var candidate = GtsId.Parse(id); + var pattern = GtsId.ParsePattern(id); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches(id)); + } + + [Fact] + public void MatchesSameTypeId() + { + var id = "gts.vendor.pkg.ns.type.v1~"; + var candidate = GtsId.Parse(id); + var pattern = GtsId.ParsePattern(id); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches(id)); + } + + [Fact] + public void DoesNotMatchDifferentIds() + { + var candidate = GtsId.Parse("gts.vendor.pkg.ns.type.v1.0"); + var pattern = GtsId.ParsePattern("gts.other.pkg.ns.type.v1.0"); + + Assert.False(candidate.Matches(pattern)); + Assert.False(candidate.Matches("gts.other.pkg.ns.type.v1.0")); + } +} diff --git a/Gts.Tests/Matching/PatternMatchingTests.cs b/Gts.Tests/Matching/PatternMatchingTests.cs new file mode 100644 index 0000000..388deeb --- /dev/null +++ b/Gts.Tests/Matching/PatternMatchingTests.cs @@ -0,0 +1,94 @@ +namespace Gts.Tests.Matching; + +public class PatternMatchingTests +{ + [Fact] + public void MatchesWildcardOnMultipleSegments() + { + var candidate = GtsId.Parse("gts.x.pkg.events.type.v1~abc.app.events.custom.v1.2"); + var pattern = GtsId.ParsePattern("gts.x.pkg.events.type.v1~abc.*"); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches("gts.x.pkg.events.type.v1~abc.*")); + } + + [Fact] + public void MatchesOnMultipleSegmentsWithOnlyWildcard() + { + var candidate = GtsId.Parse("gts.vendor.pkg.ns.type.v0~a.b.c.d.v1"); + var pattern = GtsId.ParsePattern("gts.vendor.pkg.ns.type.v0~*"); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches("gts.vendor.pkg.ns.type.v0~*")); + } + + [Fact] + public void MatchesTypePatternWithAnyMinorVersion() + { + var candidate = GtsId.Parse("gts.x.pkg.ns.type.v1.5~"); + var pattern = GtsId.ParsePattern("gts.x.pkg.ns.type.v1~"); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches("gts.x.pkg.ns.type.v1~")); + } + + [Fact] + 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")); + } + + [Fact] + public void MatchesVendorPrefixWildcard() + { + var candidate = GtsId.Parse("gts.vendor.pkg.ns.type.v1~"); + var pattern = GtsId.ParsePattern("gts.vendor.*"); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches("gts.vendor.*")); + } + + [Fact] + public void MatchesGlobalWildcard() + { + var candidate = GtsId.Parse("gts.vendor.pkg.ns.type.v1~"); + var pattern = GtsId.ParsePattern("gts.*"); + + Assert.True(candidate.Matches(pattern)); + Assert.True(candidate.Matches("gts.*")); + } + + [Fact] + public void DoesNotMatchDifferentMajorVersion() + { + var candidate = GtsId.Parse("gts.x.pkg.ns.type.v2~"); + var pattern = GtsId.ParsePattern("gts.x.pkg.ns.type.v1~"); + + Assert.False(candidate.Matches(pattern)); + Assert.False(candidate.Matches("gts.x.pkg.ns.type.v1~")); + } + + [Fact] + public void DoesNotMatchWhenCandidateShorterThanPatternWithWildcard() + { + var candidate = GtsId.Parse("gts.vendor.pkg.ns.type.v0~"); + var pattern = GtsId.ParsePattern("gts.vendor.pkg.ns.type.v0~*"); + + Assert.False(candidate.Matches(pattern)); + Assert.False(candidate.Matches("gts.vendor.pkg.ns.type.v0~*")); + } + + [Fact] + public void DoesNotMatchDifferentVersionWithTrailingWildcard() + { + var candidate = GtsId.Parse("gts.vendor.pkg.ns.type.v1.1~"); + var pattern = GtsId.ParsePattern("gts.vendor.pkg.ns.type.v0~*"); + + Assert.False(candidate.Matches(pattern)); + Assert.False(candidate.Matches("gts.vendor.pkg.ns.type.v0~*")); + } +} diff --git a/Gts.Tests/Parsing/IdentifierParserTests.cs b/Gts.Tests/Parsing/IdentifierParserTests.cs new file mode 100644 index 0000000..9e75ca5 --- /dev/null +++ b/Gts.Tests/Parsing/IdentifierParserTests.cs @@ -0,0 +1,56 @@ +using Gts.Parsing; + +namespace Gts.Tests.Parsing; +using Pidgin; + +public class IdentifierParserTests +{ + [Fact] + public void IdentifierParsesOneLetter() + { + var ident = Parsers.Identifier.ParseOrThrow("a"); + Assert.Equal("a", ident); + } + + [Fact] + public void IdentifierDoesNotParseOneDigit() + { + Assert.Throws>( + () => Parsers.Identifier.ParseOrThrow("1")); + } + + [Fact] + public void IdentifierParsesLetterFollowedByDigits() + { + var ident = Parsers.Identifier.ParseOrThrow("a123"); + Assert.Equal("a123", ident); + } + + [Fact] + public void IdentifierParsesUnderscoreFollowedByDigits() + { + var ident = Parsers.Identifier.ParseOrThrow("_123"); + Assert.Equal("_123", ident); + } + + [Fact] + public void IdentifierDoesNotParseDigitFollowedByLetters() + { + Assert.Throws>( + () => Parsers.Identifier.ParseOrThrow("1abc")); + } + + [Fact] + public void IdentifierOrWildcardParsesIdentifier() + { + var result = Parsers.IdentifierOrWildcard.ParseOrThrow("a"); + Assert.Equal("a", result); + } + + [Fact] + public void IdentifierOrWildcardParsesWildcard() + { + var result = Parsers.IdentifierOrWildcard.ParseOrThrow("*"); + Assert.Equal("*", result); + } +} diff --git a/Gts.Tests/Parsing/InstanceParserTests.cs b/Gts.Tests/Parsing/InstanceParserTests.cs new file mode 100644 index 0000000..d92ceea --- /dev/null +++ b/Gts.Tests/Parsing/InstanceParserTests.cs @@ -0,0 +1,33 @@ +using Gts.Parsing; +using Pidgin; + +namespace Gts.Tests.Parsing; + +public class InstanceParserTests +{ + [Fact] + public void InstanceParsesDoubleSegment() + { + var id = Parsers.GtsInstanceId.ParseOrThrow( + "gts.vendor.package.namespace.type.v1.0~vendor3.package3.namespace3.type3.v1.0"); + + Assert.False(id.IsType); + Assert.True(id.IsInstance); + + var segments = id.ToArray(); + Assert.Equal(2, segments.Length); + } + + [Fact] + public void InstanceParsesTripleSegment() + { + var id = Parsers.GtsInstanceId.ParseOrThrow( + "gts.vendor.package.namespace.type.v1.0~vendor3.package3.namespace3.type3.v1.0~vendor3.package3.namespace3.type3.v1.0"); + + Assert.False(id.IsType); + Assert.True(id.IsInstance); + + var segments = id.ToArray(); + Assert.Equal(3, segments.Length); + } +} \ No newline at end of file diff --git a/Gts.Tests/Parsing/PatternParserTests.cs b/Gts.Tests/Parsing/PatternParserTests.cs new file mode 100644 index 0000000..cb588c3 --- /dev/null +++ b/Gts.Tests/Parsing/PatternParserTests.cs @@ -0,0 +1,151 @@ +using Gts.Parsing; +using Pidgin; + +namespace Gts.Tests.Parsing; + +public class PatternParserTests +{ + [Fact] + 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); + Assert.Null(segment.Type); + Assert.Null(segment.Version); + } + + [Fact] + public void PatternParsesPackage() + { + var segment = Parsers.Pattern.ParseOrThrow("vendor.package.*"); + + Assert.Equal("vendor", segment.Vendor); + Assert.Equal("package", segment.Package); + Assert.Null(segment.Namespace); + Assert.Null(segment.Type); + Assert.Null(segment.Version); + } + + [Fact] + 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); + Assert.Null(segment.Type); + Assert.Null(segment.Version); + } + + [Fact] + 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); + Assert.Equal("type", segment.Type); + Assert.Null(segment.Version); + } + + [Fact] + 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); + Assert.Equal("type", segment.Type); + + Assert.NotNull(segment.Version); + var (major, minor) = segment.Version.Value; + Assert.Equal(1, major); + Assert.Equal(0, minor); + } + + [Fact] + 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); + Assert.Equal("type", segment.Type); + + Assert.NotNull(segment.Version); + var (major, minor) = segment.Version.Value; + Assert.Equal(1, major); + Assert.Equal(0, minor); + } + + [Fact] + 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); + } + + [Fact] + 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); + + var segments = id.ToArray(); + Assert.Equal(2, segments.Length); + } + + [Fact] + 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); + + var segments = id.ToArray(); + Assert.Equal(2, segments.Length); + } + + [Fact] + 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); + + var segments = id.ToArray(); + Assert.Single(segments); + } +} \ No newline at end of file diff --git a/Gts.Tests/Parsing/SectionParserTests.cs b/Gts.Tests/Parsing/SectionParserTests.cs new file mode 100644 index 0000000..f3ecc7b --- /dev/null +++ b/Gts.Tests/Parsing/SectionParserTests.cs @@ -0,0 +1,21 @@ +using Gts.Parsing; +using Pidgin; + +namespace Gts.Tests.Parsing; + +public class SectionParserTests +{ + [Fact] + public void GtsParsesLowercaseLiteral() + { + var ident = Parsers.GtsPrefix.ParseOrThrow("gts"); + Assert.Equal("gts", ident); + } + + [Fact] + public void GtsDoesNotParseUppercaseLiteral() + { + Assert.Throws>( + () => Parsers.GtsPrefix.ParseOrThrow("GTS")); + } +} \ No newline at end of file diff --git a/Gts.Tests/Parsing/SegmentParserTests.cs b/Gts.Tests/Parsing/SegmentParserTests.cs new file mode 100644 index 0000000..d4c4adb --- /dev/null +++ b/Gts.Tests/Parsing/SegmentParserTests.cs @@ -0,0 +1,35 @@ +using Gts.Parsing; +using Pidgin; + +namespace Gts.Tests.Parsing; + +public class SegmentParserTests +{ + [Fact] + 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 new file mode 100644 index 0000000..f355e7b --- /dev/null +++ b/Gts.Tests/Parsing/TypeParserTests.cs @@ -0,0 +1,33 @@ +using Gts.Parsing; +using Pidgin; + +namespace Gts.Tests.Parsing; + +public class TypeParserTests +{ + [Fact] + public void TypeParsesSingleSegment() + { + var id = Parsers.GtsTypeId.ParseOrThrow( + "gts.vendor.package.namespace.type.v1.0~"); + + Assert.True(id.IsType); + Assert.False(id.IsInstance); + + var segments = id.ToArray(); + Assert.Single(segments); + } + + [Fact] + public void TypeParsesDoubleSegment() + { + var id = Parsers.GtsTypeId.ParseOrThrow( + "gts.vendor.package.namespace.type.v1.0~vendor2.package2.namespace2.type2.v1.0~"); + + Assert.True(id.IsType); + Assert.False(id.IsInstance); + + var segments = id.ToArray(); + Assert.Equal(2, segments.Length); + } +} \ No newline at end of file diff --git a/Gts.Tests/Parsing/VersionParserTests.cs b/Gts.Tests/Parsing/VersionParserTests.cs new file mode 100644 index 0000000..b5b504f --- /dev/null +++ b/Gts.Tests/Parsing/VersionParserTests.cs @@ -0,0 +1,49 @@ +using Gts.Parsing; +using Pidgin; + +namespace Gts.Tests.Parsing; + +public class VersionParserTests +{ + [Fact] + public void VersionMajorParsesNumberWithVPrefix() + { + var major = Parsers.VersionMajor.ParseOrThrow("v123"); + Assert.Equal(123, major); + } + + [Fact] + public void VersionMajorDoesNotParseNumberWithoutVPrefix() + { + Assert.Throws>(() => Parsers.VersionMajor.ParseOrThrow("123")); + } + + [Fact] + public void VersionMinorParsesNumberWithoutVPrefix() + { + var minor = Parsers.VersionMinor.ParseOrThrow("123"); + Assert.Equal(123, minor); + } + + [Fact] + public void VersionMinorDoesNotParseNumberWithVPrefix() + { + Assert.Throws>(() => Parsers.VersionMinor.ParseOrThrow("v123")); + } + + [Fact] + public void VersionFullParsesFullVersionString() + { + var (major, minor) = Parsers.VersionFull.ParseOrThrow("v123.456"); + Assert.Equal(123, major); + Assert.Equal(456, minor); + } + + [Fact] + public void VersionFullParsesPartialVersionString() + { + var (major, minor) = Parsers.VersionFull.ParseOrThrow("v123"); + Assert.Equal(123, major); + Assert.Null(minor); + } +} \ No newline at end of file diff --git a/Gts.Tests/Query/GtsQueryTests.cs b/Gts.Tests/Query/GtsQueryTests.cs new file mode 100644 index 0000000..81e1062 --- /dev/null +++ b/Gts.Tests/Query/GtsQueryTests.cs @@ -0,0 +1,147 @@ +using System.Text.Json.Nodes; +using Gts; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Query; + +public class GtsQueryTests +{ + [Fact] + public void TryParse_rejects_filters_on_type_pattern_tilde() + { + var ok = GtsQuery.TryParse("""gts.acme.order.ns.invoice.v1~[foo="bar"]""", out _, out var err); + Assert.False(ok); + Assert.Contains("filters cannot be used with type patterns", err, StringComparison.Ordinal); + } + + [Fact] + public void TryParse_rejects_filters_on_type_wildcard_tilde_star() + { + var ok = GtsQuery.TryParse("gts.acme.order.ns.*~*[foo=bar]", out _, out var err); + Assert.False(ok); + Assert.Contains("filters cannot be used with type patterns", err, StringComparison.Ordinal); + } + + [Fact] + public void TryParse_rejects_missing_close_bracket() + { + var ok = GtsQuery.TryParse("gts.acme.order.ns.invoice.v1.0[foo=bar", out _, out var err); + Assert.False(ok); + Assert.Contains("missing closing bracket", err, StringComparison.Ordinal); + } + + [Fact] + public void TryParse_accepts_instance_with_filters() + { + var ok = GtsQuery.TryParse( + """gts.acme.order.ns.invoice.v1~x.ns._.i1.v1.0[status="active"]""", + out var q, + out var err); + Assert.True(ok); + Assert.Null(err); + Assert.NotNull(q); + Assert.False(q!.IsWildcard); + Assert.Equal("active", q.Filters["status"]); + } + + [Fact] + public async Task QueryAsync_wildcard_and_filter() + { + var reg = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await reg.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(""" + { + "gtsId": "gts.acme.order.ns.invoice.v1~x.ns._.a.v1.0", + "status": "active", + "name": "A" + } + """)!.AsObject())); + await reg.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(""" + { + "gtsId": "gts.acme.order.ns.invoice.v1~x.ns._.b.v1.0", + "status": "idle", + "name": "B" + } + """)!.AsObject())); + + var r = await reg.QueryAsync("""gts.acme.order.ns.*[status="active"]""", 10); + Assert.True(r.Ok); + Assert.Single(r.Results); + Assert.Equal("active", r.Results[0]["status"]?.GetValue()); + } + + [Fact] + public async Task QueryAsync_exact_id() + { + var reg = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + const string id = "gts.acme.order.ns.invoice.v1~x.ns._.only.v1.0"; + await reg.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse($$""" + { "gtsId": "{{id}}", "k": 1 } + """)!.AsObject())); + + var r = await reg.QueryAsync(id, 10); + Assert.True(r.Ok); + Assert.Single(r.Results); + Assert.Equal(1, r.Results[0]["k"]?.GetValue()); + } + + [Fact] + public async Task QueryAsync_limit_normalized() + { + var reg = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + for (var i = 0; i < 5; i++) + { + await reg.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse($$""" + { "gtsId": "gts.acme.order.ns.invoice.v1~x.ns._.x{{i}}.v1.0", "n": {{i}} } + """)!.AsObject())); + } + + var r = await reg.QueryAsync("gts.acme.order.ns.*", 2); + Assert.True(r.Ok); + Assert.Equal(2, r.Results.Count); + Assert.Equal(2, r.Limit); + } + + [Fact] + public void TryFilterIdentifiers_matches_wildcard() + { + var ids = new[] + { + GtsId.Parse("gts.acme.order.ns.invoice.v1~x.ns._.a.v1.0"), + GtsId.Parse("gts.vendor.other.ns.widget.v1~x.ns._.b.v1.0") + }; + var ok = GtsQuery.TryFilterIdentifiers("gts.acme.order.ns.*", ids, 10, out var matched, out var err); + Assert.True(ok); + Assert.Null(err); + Assert.NotNull(matched); + Assert.Single(matched); + Assert.Equal("gts.acme.order.ns.invoice.v1~x.ns._.a.v1.0", matched[0].Id); + } + + [Fact] + public void TryFilterIdentifiers_rejects_when_filters_present() + { + var ids = new[] { GtsId.Parse("gts.acme.order.ns.invoice.v1~x.ns._.a.v1.0") }; + var ok = GtsQuery.TryFilterIdentifiers("gts.acme.order.ns.*[x=1]", ids, 10, out _, out var err); + Assert.False(ok); + Assert.Contains("attribute filters require entity JSON", err, StringComparison.Ordinal); + } + + [Fact] + public void Execute_uses_star_filter_as_non_empty() + { + var entities = new[] + { + GtsJsonEntity.ExtractEntity(JsonNode.Parse(""" + { "gtsId": "gts.acme.order.ns.invoice.v1~x.ns._.a.v1.0", "tag": "x" } + """)!.AsObject()), + GtsJsonEntity.ExtractEntity(JsonNode.Parse(""" + { "gtsId": "gts.acme.order.ns.invoice.v1~x.ns._.b.v1.0", "tag": "" } + """)!.AsObject()) + }; + Assert.True(GtsQuery.TryParse("gts.acme.order.ns.*[tag=*]", out var q, out _) && q is not null); + var hits = GtsQuery.Execute(entities, q, 10); + Assert.Single(hits); + Assert.Equal("gts.acme.order.ns.invoice.v1~x.ns._.a.v1.0", hits[0]["gtsId"]?.GetValue()); + } +} diff --git a/Gts.Tests/Validation/InstanceCastTests.cs b/Gts.Tests/Validation/InstanceCastTests.cs new file mode 100644 index 0000000..c7a03a7 --- /dev/null +++ b/Gts.Tests/Validation/InstanceCastTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json.Nodes; +using Gts; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Validation; + +public class InstanceCastTests +{ + [Fact] + public async Task CastInstanceAsync_upgrade_minor_when_evolution_allows() + { + const string s0 = """ + { + "$$id": "gts://gts.x.cast.demo.item.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["a"], + "properties": { + "a": { "type": "string" } + } + } + """; + + const string s1 = """ + { + "$$id": "gts://gts.x.cast.demo.item.v1.1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["a"], + "properties": { + "a": { "type": "string" }, + "b": { "type": "string", "default": "default-b" } + } + } + """; + + const string instance = """ + { + "gtsId": "gts.x.cast.demo.item.v1.0~x.cast.ns.myinst.v1.0", + "a": "hello" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s0)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s1)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var to = GtsId.Parse("gts.x.cast.demo.item.v1.1~"); + var result = await registry.CastInstanceAsync( + "gts.x.cast.demo.item.v1.0~x.cast.ns.myinst.v1.0", + to); + + Assert.True(result.Ok); + Assert.NotNull(result.CastedContent); + Assert.Equal("default-b", result.CastedContent!["b"]!.GetValue()); + Assert.True(result.Comparison!.IsBackwardEvolutionCompatible); + } + + [Fact] + public async Task CastInstanceAsync_fails_when_backward_evolution_blocks_upgrade() + { + const string s0 = """ + { + "$$id": "gts://gts.x.cast.demo.doc.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + } + """; + + const string s1 = """ + { + "$$id": "gts://gts.x.cast.demo.doc.v1.1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["x"] + } + """; + + const string instance = """ + { + "gtsId": "gts.x.cast.demo.doc.v1.0~x.cast.ns.empty.v1.0" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s0)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s1)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var result = await registry.CastInstanceAsync( + "gts.x.cast.demo.doc.v1.0~x.cast.ns.empty.v1.0", + GtsId.Parse("gts.x.cast.demo.doc.v1.1~")); + + Assert.False(result.Ok); + Assert.Equal("IncompatibleMinorEvolution", result.FailureReason); + Assert.NotNull(result.Comparison); + Assert.False(result.Comparison!.IsBackwardEvolutionCompatible); + } + + [Fact] + public async Task CastInstanceAsync_fails_when_target_is_not_minor_variant() + { + const string sWidget = """ + { + "$$id": "gts://gts.x.cast.demo.widget.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["k"], + "properties": { "k": { "type": "string" } } + } + """; + + const string sOther = """ + { + "$$id": "gts://gts.x.cast.other.thing.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["k"], + "properties": { "k": { "type": "string" } } + } + """; + + const string instance = """ + { + "gtsId": "gts.x.cast.demo.widget.v1.0~x.cast.ns.i.v1.0", + "k": "v" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(sWidget)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(sOther)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var result = await registry.CastInstanceAsync( + "gts.x.cast.demo.widget.v1.0~x.cast.ns.i.v1.0", + GtsId.Parse("gts.x.cast.other.thing.v1.0~")); + + Assert.False(result.Ok); + Assert.Equal("NotMinorVariantPair", result.FailureReason); + } +} diff --git a/Gts.Tests/Validation/InstanceValidationTests.cs b/Gts.Tests/Validation/InstanceValidationTests.cs new file mode 100644 index 0000000..fa861d7 --- /dev/null +++ b/Gts.Tests/Validation/InstanceValidationTests.cs @@ -0,0 +1,307 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Validation; + +public class InstanceValidationTests +{ + private static async Task RegistryWithSchemasAndInstanceAsync( + string baseSchemaJson, + string derivedSchemaJson, + string instanceJson) + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(baseSchemaJson)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(derivedSchemaJson)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instanceJson)!.AsObject())); + return registry; + } + + [Fact] + public async Task Valid_well_known_instance_passes() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "type", "tenantId", "occurredAt"], + "properties": { + "type": { "type": "string" }, + "id": { "type": "string" }, + "tenantId": { "type": "string", "format": "uuid" }, + "occurredAt": { "type": "string", "format": "date-time" }, + "payload": { "type": "object" } + }, + "additionalProperties": false + } + """; + + const string derivedSchema = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~x.commerce.orders.order_placed.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.test6.events.type.v1~" }, + { + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "gts.x.test6.events.type.v1~x.commerce.orders.order_placed.v1.0~" }, + "payload": { + "type": "object", + "required": ["orderId", "customerId", "totalAmount", "items"], + "properties": { + "orderId": { "type": "string", "format": "uuid" }, + "customerId": { "type": "string", "format": "uuid" }, + "totalAmount": { "type": "number" }, + "items": { "type": "array", "items": { "type": "object" } } + } + } + } + } + ] + } + """; + + const string instance = """ + { + "type": "gts.x.test6.events.type.v1~x.commerce.orders.order_placed.v1.0~", + "id": "gts.x.test6.events.type.v1~x.commerce.orders.order_placed.v1.0~x.y._.some_event.v1.0", + "tenantId": "11111111-2222-3333-8444-555555555555", + "occurredAt": "2025-09-20T18:35:00Z", + "payload": { + "orderId": "af0e3c1b-8f1e-4a27-9a9b-b7b9b70c1f01", + "customerId": "0f2e4a9b-1c3d-4e5f-8a9b-0c1d2e3f4a5b", + "totalAmount": 149.99, + "items": [ + { "sku": "SKU-ABC-001", "name": "Wireless Mouse", "qty": 1, "price": 49.99 } + ] + } + } + """; + + var registry = await RegistryWithSchemasAndInstanceAsync(baseSchema, derivedSchema, instance); + var result = await registry.ValidateInstanceAsync( + "gts.x.test6.events.type.v1~x.commerce.orders.order_placed.v1.0~x.y._.some_event.v1.0"); + + Assert.True(result.Ok); + Assert.Equal( + "gts.x.test6.events.type.v1~x.commerce.orders.order_placed.v1.0~x.y._.some_event.v1.0", + result.Id); + } + + [Fact] + public async Task Invalid_instance_fails_schema_validation() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "type", "tenantId", "occurredAt"], + "properties": { + "type": { "type": "string" }, + "id": { "type": "string" }, + "tenantId": { "type": "string", "format": "uuid" }, + "occurredAt": { "type": "string", "format": "date-time" }, + "payload": { "type": "object" } + }, + "additionalProperties": false + } + """; + + const string derivedSchema = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~x.test6.invalid.event.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.test6.events.type.v1~" }, + { + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "gts.x.test6.events.type.v1~x.test6.invalid.event.v1.0~" }, + "payload": { + "type": "object", + "required": ["requiredField"], + "properties": { + "requiredField": { "type": "string" } + } + } + } + } + ] + } + """; + + const string instance = """ + { + "type": "gts.x.test6.events.type.v1~x.test6.invalid.event.v1.0~", + "id": "gts.x.test6.events.type.v1~x.test6.invalid.event.v1.0~x.y._.some_event2.v1.0", + "tenantId": "11111111-2222-3333-8444-555555555555", + "occurredAt": "2025-09-20T18:35:00Z", + "payload": { + "someOtherField": "value" + } + } + """; + + var registry = await RegistryWithSchemasAndInstanceAsync(baseSchema, derivedSchema, instance); + var result = await registry.ValidateInstanceAsync( + "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.NotNull(result.SchemaErrors); + Assert.NotEmpty(result.SchemaErrors!); + } + + [Fact] + 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); + } + + [Fact] + public async Task Anonymous_instance_validated_by_uuid() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.test6anon.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "type", "tenantId", "occurredAt"], + "properties": { + "type": { "type": "string" }, + "id": { "type": "string", "format": "uuid" }, + "tenantId": { "type": "string", "format": "uuid" }, + "occurredAt": { "type": "string", "format": "date-time" }, + "payload": { "type": "object" } + }, + "additionalProperties": false + } + """; + + const string derivedSchema = """ + { + "$$id": "gts://gts.x.test6anon.events.type.v1~x.commerce.orders.order_placed.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.test6anon.events.type.v1~" }, + { + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "gts.x.test6anon.events.type.v1~x.commerce.orders.order_placed.v1.0~" }, + "payload": { + "type": "object", + "required": ["orderId", "customerId", "totalAmount", "items"], + "properties": { + "orderId": { "type": "string", "format": "uuid" }, + "customerId": { "type": "string", "format": "uuid" }, + "totalAmount": { "type": "number" }, + "items": { "type": "array", "items": { "type": "object" } } + } + } + } + } + ] + } + """; + + const string instance = """ + { + "type": "gts.x.test6anon.events.type.v1~x.commerce.orders.order_placed.v1.0~", + "id": "7a1d2f34-5678-49ab-9012-abcdef123456", + "tenantId": "11111111-2222-3333-8444-555555555555", + "occurredAt": "2025-09-20T18:35:00Z", + "payload": { + "orderId": "af0e3c1b-8f1e-4a27-9a9b-b7b9b70c1f01", + "customerId": "0f2e4a9b-1c3d-4e5f-8a9b-0c1d2e3f4a5b", + "totalAmount": 149.99, + "items": [ + { "sku": "SKU-ABC-001", "name": "Wireless Mouse", "qty": 1, "price": 49.99 } + ] + } + } + """; + + var registry = await RegistryWithSchemasAndInstanceAsync(baseSchema, derivedSchema, instance); + var result = await registry.ValidateInstanceAsync("7a1d2f34-5678-49ab-9012-abcdef123456"); + + Assert.True(result.Ok); + Assert.Equal("7a1d2f34-5678-49ab-9012-abcdef123456", result.Id); + } + + [Fact] + public async Task Anonymous_invalid_instance_fails() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.test6anon.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "type", "tenantId", "occurredAt"], + "properties": { + "type": { "type": "string" }, + "id": { "type": "string", "format": "uuid" }, + "tenantId": { "type": "string", "format": "uuid" }, + "occurredAt": { "type": "string", "format": "date-time" }, + "payload": { "type": "object" } + }, + "additionalProperties": false + } + """; + + const string derivedSchema = """ + { + "$$id": "gts://gts.x.test6anon.events.type.v1~x.test6anon.invalid.event.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.test6anon.events.type.v1~" }, + { + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "gts.x.test6anon.events.type.v1~x.test6anon.invalid.event.v1.0~" }, + "payload": { + "type": "object", + "required": ["requiredField"], + "properties": { + "requiredField": { "type": "string" } + } + } + } + } + ] + } + """; + + const string instance = """ + { + "type": "gts.x.test6anon.events.type.v1~x.test6anon.invalid.event.v1.0~", + "id": "8b2e3f45-6789-4abc-8123-bcdef1234567", + "tenantId": "11111111-2222-3333-8444-555555555555", + "occurredAt": "2025-09-20T18:35:00Z", + "payload": { + "someOtherField": "value" + } + } + """; + + var registry = await RegistryWithSchemasAndInstanceAsync(baseSchema, derivedSchema, instance); + var result = await registry.ValidateInstanceAsync("8b2e3f45-6789-4abc-8123-bcdef1234567"); + + Assert.False(result.Ok); + Assert.Equal("SchemaValidationFailed", result.FailureReason); + } +} diff --git a/Gts.Tests/Validation/MinorVersionCompatibilityTests.cs b/Gts.Tests/Validation/MinorVersionCompatibilityTests.cs new file mode 100644 index 0000000..95c9567 --- /dev/null +++ b/Gts.Tests/Validation/MinorVersionCompatibilityTests.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Nodes; +using Gts; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Validation; + +public class MinorVersionCompatibilityTests +{ + [Fact] + public void StripLastMinorFromTypeId_strips_only_final_minor() + { + Assert.Equal( + "gts.vendor.pkg.ns.type.v1~", + GtsTypeFamily.StripLastMinorFromTypeId("gts.vendor.pkg.ns.type.v1.0~")); + + Assert.Equal( + "gts.base.v1.0~derived.type.v2~", + GtsTypeFamily.StripLastMinorFromTypeId("gts.base.v1.0~derived.type.v2.3~")); + } + + [Fact] + public void AreSameLogicalTypeMinorVariants_requires_explicit_minors_on_last_segment() + { + var a = GtsId.Parse("gts.vendor.pkg.ns.type.v1.0~"); + var b = GtsId.Parse("gts.vendor.pkg.ns.type.v1.1~"); + Assert.True(GtsTypeFamily.AreSameLogicalTypeMinorVariants(a, b)); + + var majorOnly = GtsId.Parse("gts.vendor.pkg.ns.type.v1~"); + Assert.False(GtsTypeFamily.AreSameLogicalTypeMinorVariants(majorOnly, a)); + } + + [Fact] + public void CompareSchemas_full_compatibility_when_bodies_match_modulo_minor() + { + const string s1 = """ + { + "$$id": "gts://gts.x.compat.demo.widget.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + """; + + const string s2 = """ + { + "$$id": "gts://gts.x.compat.demo.widget.v1.1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + """; + + var id1 = GtsId.Parse("gts.x.compat.demo.widget.v1.0~"); + var id2 = GtsId.Parse("gts.x.compat.demo.widget.v1.1~"); + var o1 = JsonNode.Parse(s1)!.AsObject(); + var o2 = JsonNode.Parse(s2)!.AsObject(); + + var result = GtsSchemaMinorVersionCompatibility.CompareSchemas(id1, o1, id2, o2); + Assert.True(result.AreCompatible); + } + + [Fact] + public void CompareSchemas_detects_structural_drift() + { + const string s1 = """ + { + "$$id": "gts://gts.x.compat.demo.item.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + } + """; + + const string s2 = """ + { + "$$id": "gts://gts.x.compat.demo.item.v1.1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["k"] + } + """; + + var id1 = GtsId.Parse("gts.x.compat.demo.item.v1.0~"); + var id2 = GtsId.Parse("gts.x.compat.demo.item.v1.1~"); + + var result = GtsSchemaMinorVersionCompatibility.CompareSchemas( + id1, + JsonNode.Parse(s1)!.AsObject(), + id2, + JsonNode.Parse(s2)!.AsObject()); + + Assert.False(result.AreCompatible); + Assert.NotNull(result.Reason); + } + + [Fact] + public async Task Registry_CheckMinorVersionCompatibilityAsync_reports_incompatible_pair() + { + const string s1 = """ + { + "$$id": "gts://gts.x.compat.demo.doc.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + } + """; + + const string s2 = """ + { + "$$id": "gts://gts.x.compat.demo.doc.v1.1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["x"] + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s1)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s2)!.AsObject())); + + var report = await registry.CheckMinorVersionCompatibilityAsync(); + + Assert.Equal(2, report.SchemaCount); + Assert.False(report.AreAllCompatible); + var issue = Assert.Single(report.IncompatiblePairs); + var ids = new[] { issue.SchemaIdA.Id, issue.SchemaIdB.Id }; + Assert.Contains(ids, id => id.Contains("v1.0", StringComparison.Ordinal)); + Assert.Contains(ids, id => id.Contains("v1.1", StringComparison.Ordinal)); + } + + [Fact] + public async Task CompareMinorVersionSchemasAsync_reports_structural_and_evolution_for_pair() + { + const string s1 = """ + { + "$$id": "gts://gts.x.compat.demo.pair.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["a"], + "properties": { "a": { "type": "string" } } + } + """; + + const string s2 = """ + { + "$$id": "gts://gts.x.compat.demo.pair.v1.1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["a"], + "properties": { "a": { "type": "string" } } + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s1)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(s2)!.AsObject())); + + var id0 = GtsId.Parse("gts.x.compat.demo.pair.v1.0~"); + var id1 = GtsId.Parse("gts.x.compat.demo.pair.v1.1~"); + var cmp = await registry.CompareMinorVersionSchemasAsync(id0, id1); + + Assert.True(cmp.AreMinorVariantPair); + Assert.Equal(id0.Id, cmp.OlderSchemaId!.Id); + Assert.Equal(id1.Id, cmp.NewerSchemaId!.Id); + Assert.True(cmp.IsStructurallyCompatible); + Assert.True(cmp.IsBackwardEvolutionCompatible); + Assert.True(cmp.IsForwardEvolutionCompatible); + } +} diff --git a/Gts.Tests/Validation/RelationshipResolutionTests.cs b/Gts.Tests/Validation/RelationshipResolutionTests.cs new file mode 100644 index 0000000..6302d99 --- /dev/null +++ b/Gts.Tests/Validation/RelationshipResolutionTests.cs @@ -0,0 +1,196 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Validation; + +public class RelationshipResolutionTests +{ + [Fact] + public async Task Fully_linked_schemas_and_instance_are_consistent() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.relres.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + } + """; + + const string derivedSchema = """ + { + "$$id": "gts://gts.x.relres.events.type.v1~x.relres.orders.order_placed.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.relres.events.type.v1~" } + ] + } + """; + + const string instance = """ + { + "type": "gts.x.relres.events.type.v1~x.relres.orders.order_placed.v1.0~", + "id": "gts.x.relres.events.type.v1~x.relres.orders.order_placed.v1.0~x.relres._.evt.v1.0" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(baseSchema)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(derivedSchema)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var result = await registry.ResolveRelationshipsAsync(); + + Assert.True(result.IsConsistent); + Assert.Equal(3, result.EntityCount); + Assert.Equal(2, result.SchemaCount); + Assert.Equal(1, result.InstanceCount); + Assert.Empty(result.BrokenReferences); + } + + [Fact] + public async Task Missing_schema_target_from_ref_is_reported() + { + const string derivedOnly = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~x.test6.rel.missing_base.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.test6.events.type.v1~" } + ] + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(derivedOnly)!.AsObject())); + + var result = await registry.ResolveRelationshipsAsync(); + + Assert.False(result.IsConsistent); + var br = Assert.Single(result.BrokenReferences); + Assert.Equal("MissingSchema", br.Reason); + Assert.Equal("gts.x.test6.events.type.v1~", br.ReferencedId); + Assert.True(br.SourceIsSchema); + } + + [Fact] + public async Task Missing_instance_target_is_reported() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.test6.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "peer": { "type": "string" } + } + } + """; + + const string instance = """ + { + "type": "gts.x.test6.events.type.v1~", + "id": "gts.x.test6.events.type.v1~x.test6.rel.peer_self.v1.0", + "peer": "gts.x.test6.events.type.v1~x.test6.rel.peer_other.v1.0" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(baseSchema)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var result = await registry.ResolveRelationshipsAsync(); + + Assert.False(result.IsConsistent); + var br = Assert.Single(result.BrokenReferences); + Assert.Equal("MissingInstance", br.Reason); + Assert.Equal("gts.x.test6.events.type.v1~x.test6.rel.peer_other.v1.0", br.ReferencedId); + Assert.Contains("peer", br.SourcePath, StringComparison.Ordinal); + } + + [Fact] + public async Task Pattern_reference_does_not_require_stored_target() + { + const string schema = """ + { + "$$id": "gts://gts.x.relres4.modules.capability.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "accepts": { "const": "gts.x.relres4.*" } + } + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(schema)!.AsObject())); + + var result = await registry.ResolveRelationshipsAsync(); + + Assert.True(result.IsConsistent); + } + + [Fact] + public async Task Chained_instance_without_type_field_reports_missing_derived_schema() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.relres5.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + } + """; + + const string instance = """ + { + "id": "gts.x.relres5.events.type.v1~x.relres5.orders.child.v1.0~x.relres5._.i.v1.0" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(baseSchema)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + var result = await registry.ResolveRelationshipsAsync(); + + Assert.False(result.IsConsistent); + var br = Assert.Single(result.BrokenReferences); + Assert.Equal("MissingTypeBinding", br.Reason); + Assert.Equal("gts.x.relres5.events.type.v1~x.relres5.orders.child.v1.0~", br.ReferencedId); + Assert.Equal("(type binding)", br.SourcePath); + } + + [Fact] + public async Task GetAllAsync_includes_anonymous_instances_in_count() + { + const string baseSchema = """ + { + "$$id": "gts://gts.x.relres6anon.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "type"], + "properties": { + "type": { "type": "string" }, + "id": { "type": "string", "format": "uuid" } + } + } + """; + + const string instance = """ + { + "type": "gts.x.relres6anon.events.type.v1~", + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + } + """; + + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(baseSchema)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(instance)!.AsObject())); + + Assert.Equal(2, await registry.CountAsync()); + var all = await registry.GetAllAsync(); + Assert.Equal(2, all.Count); + } +} diff --git a/Gts.Tests/Validation/SchemaValidationTests.cs b/Gts.Tests/Validation/SchemaValidationTests.cs new file mode 100644 index 0000000..38fe937 --- /dev/null +++ b/Gts.Tests/Validation/SchemaValidationTests.cs @@ -0,0 +1,192 @@ +using System.Text.Json.Nodes; +using Gts.Extraction; +using Gts.Store; + +namespace Gts.Tests.Validation; + +public class SchemaValidationTests +{ + // Base has no "required" so FlattenSchema (which does not inline $$ref) matches derivation checks with allOf + $$ref. + private const string BaseSchema = """ + { + "$$id": "gts://gts.x.schema12.events.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "type": { "type": "string" }, + "id": { "type": "string" }, + "tenantId": { "type": "string", "format": "uuid" }, + "payload": { "type": "object" } + }, + "additionalProperties": false + } + """; + + private const string DerivedSchemaValid = """ + { + "$$id": "gts://gts.x.schema12.events.type.v1~x.commerce.orders.order_placed.v1.0~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { "$$ref": "gts://gts.x.schema12.events.type.v1~" }, + { + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "gts.x.schema12.events.type.v1~x.commerce.orders.order_placed.v1.0~" }, + "payload": { + "type": "object", + "required": ["orderId"], + "properties": { + "orderId": { "type": "string", "format": "uuid" } + } + } + } + } + ] + } + """; + + [Fact] + public async Task Base_schema_passes_without_precedent_chain() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(BaseSchema)!.AsObject())); + + var r = await registry.ValidateSchemaAsync("gts.x.schema12.events.type.v1~"); + + Assert.True(r.Ok); + Assert.Equal("gts.x.schema12.events.type.v1~", r.SchemaId); + Assert.Null(r.FailureReason); + } + + [Fact] + public async Task Derived_schema_passes_when_precedent_stored() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(BaseSchema)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(DerivedSchemaValid)!.AsObject())); + + var r = await registry.ValidateSchemaAsync( + GtsId.Parse("gts.x.schema12.events.type.v1~x.commerce.orders.order_placed.v1.0~")); + + Assert.True(r.Ok); + } + + [Fact] + public async Task Document_overload_passes_without_saving_derived() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(BaseSchema)!.AsObject())); + + var derived = JsonNode.Parse(DerivedSchemaValid)!.AsObject(); + var id = GtsId.Parse("gts.x.schema12.events.type.v1~x.commerce.orders.order_placed.v1.0~"); + var r = await registry.ValidateSchemaAsync(id, derived); + + Assert.True(r.Ok); + Assert.Equal(1, await registry.CountAsync()); + } + + [Fact] + public async Task Derived_fails_when_precedent_missing() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var derived = JsonNode.Parse(DerivedSchemaValid)!.AsObject(); + var id = GtsId.Parse("gts.x.schema12.events.type.v1~x.commerce.orders.order_placed.v1.0~"); + + var r = await registry.ValidateSchemaAsync(id, derived); + + Assert.False(r.Ok); + Assert.Equal("PrecedentIncompatible", r.FailureReason); + Assert.Contains(r.Errors!, e => e.Contains("Precedent schema", StringComparison.Ordinal)); + } + + [Fact] + public async Task Invalid_ref_format_fails() + { + const string badRef = """ + { + "$$id": "gts://gts.x.schema12.badref.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "x": { "$ref": "https://example.com/other.json" } + } + } + """; + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(badRef)!.AsObject())); + + var r = await registry.ValidateSchemaAsync("gts.x.schema12.badref.type.v1~"); + + Assert.False(r.Ok); + Assert.Equal("InvalidRefFormat", r.FailureReason); + Assert.NotNull(r.Errors); + Assert.NotEmpty(r.Errors); + } + + [Fact] + public async Task Non_type_id_returns_invalid() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var r = await registry.ValidateSchemaAsync("gts.x.schema12.events.type.v1.0"); + + Assert.False(r.Ok); + Assert.Equal("InvalidSchemaId", r.FailureReason); + } + + [Fact] + public async Task Missing_entity_returns_schema_not_found() + { + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + var r = await registry.ValidateSchemaAsync("gts.x.schema12.events.type.v1~"); + + Assert.False(r.Ok); + Assert.Equal("SchemaNotFound", r.FailureReason); + } + + [Fact] + public async Task Instance_id_returns_invalid_schema_id() + { + const string baseOnly = """ + { + "$$id": "gts://gts.x.schema12.inst.type.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { "a": { "type": "string" } } + } + """; + const string inst = """ + { + "gtsId": "gts.x.schema12.inst.type.v1~x.y._.i1.v1.0", + "a": "hi" + } + """; + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(baseOnly)!.AsObject())); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(inst)!.AsObject())); + + 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); + } + + [Fact] + public async Task Stored_non_schema_under_type_id_returns_not_a_schema() + { + const string notSchemaButTypeId = """ + { + "$$id": "gts://gts.x.schema12.misc.type.v1~", + "payload": {} + } + """; + var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); + await registry.SaveAsync(GtsJsonEntity.ExtractEntity(JsonNode.Parse(notSchemaButTypeId)!.AsObject())); + + var r = await registry.ValidateSchemaAsync("gts.x.schema12.misc.type.v1~"); + + Assert.False(r.Ok); + Assert.Equal("NotASchema", r.FailureReason); + } +} diff --git a/Gts/Extraction/ExtractResult.cs b/Gts/Extraction/ExtractResult.cs new file mode 100644 index 0000000..8fb3698 --- /dev/null +++ b/Gts/Extraction/ExtractResult.cs @@ -0,0 +1,17 @@ +namespace Gts.Extraction; + +/// +/// Result of extracting the primary entity/schema ID from a JSON object. +/// +/// The effective entity or schema ID; null if none found. +/// The schema/type ID if determined. +/// The property name from which the entity ID was taken. +/// The property name from which the schema ID was taken (or derived). +/// True if the JSON object is treated as a JSON Schema (has $schema). +public sealed record ExtractResult( + string? Id, + string? SchemaId, + string? SelectedEntityField, + string? SelectedSchemaIdField, + bool IsSchema +); diff --git a/Gts/Extraction/GtsExtractOptions.cs b/Gts/Extraction/GtsExtractOptions.cs new file mode 100644 index 0000000..d504d49 --- /dev/null +++ b/Gts/Extraction/GtsExtractOptions.cs @@ -0,0 +1,25 @@ +namespace Gts.Extraction; + +/// +/// Configuration for which JSON property names to use when extracting entity and schema IDs. +/// +public sealed class GtsExtractOptions +{ + /// Default options with standard entity and schema property names. + public static GtsExtractOptions Default { get; } = new(); + + /// Property names to check for the entity ID, in priority order. + public IReadOnlyList EntityIdPropertyNames { get; init; } = + [ + "gtsId", "gtsIid", "gtsOid", "gtsI", + "gts_id", "gts_oid", "gts_iid", "id", + "$$id", "$id" + ]; + + /// Property names to check for the schema/type ID, in priority order. + public IReadOnlyList SchemaIdPropertyNames { get; init; } = + [ + "gtsTid", "gtsType", "gtsT", "gts_t", "gts_tid", "gts_type", + "type", "schema" + ]; +} diff --git a/Gts/Extraction/GtsJsonEntity.cs b/Gts/Extraction/GtsJsonEntity.cs new file mode 100644 index 0000000..b4848e9 --- /dev/null +++ b/Gts/Extraction/GtsJsonEntity.cs @@ -0,0 +1,363 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Gts.Extraction; + +/// +/// A JSON object with extracted GTS IDs and all GTS references in the tree. +/// +public sealed class GtsJsonEntity +{ + private const string GtsUriPrefix = "gts://"; + + /// Parsed GTS ID if the entity has a valid GTS identifier; null for anonymous instances. + public GtsId? GtsId { get; } + + /// Schema/type ID (may be derived from entity ID chain or from schema fields). + public string SchemaId { get; } + + /// Property name from which the entity ID was taken, or null. + public string? SelectedEntityField { get; } + + /// Property name from which the schema ID was taken or derived, or null. + public string? SelectedSchemaIdField { get; } + + /// True if the document is treated as a JSON Schema (has $schema). + public bool IsSchema { get; } + + /// The source JSON object. + public JsonObject Content { get; } + + /// All GTS ID references found in the tree, with their paths. + public IReadOnlyList GtsRefs { get; } + + /// Display label (e.g. GTS ID or file name). + public string Label { get; } + + /// Builds a GtsJsonEntity with extracted IDs and references. + internal GtsJsonEntity( + GtsId? gtsId, + string schemaId, + string? selectedEntityField, + string? selectedSchemaIdField, + bool isSchema, + JsonObject content, + IReadOnlyList gtsRefs, + string label) + { + GtsId = gtsId; + SchemaId = schemaId; + SelectedEntityField = selectedEntityField; + SelectedSchemaIdField = selectedSchemaIdField; + IsSchema = isSchema; + Content = content; + GtsRefs = gtsRefs; + Label = label; + } + + /// + /// Extracts the primary entity/schema ID from a JSON object. + /// + /// Root JSON object. + /// Optional; uses default entity/schema property names if null. + /// ExtractResult with Id, SchemaId, which fields were used, and IsSchema. + public static ExtractResult ExtractId(JsonObject json, GtsExtractOptions? options = null) + { + options ??= GtsExtractOptions.Default; + var entity = ExtractEntityInternal(json, options); + + string? id; + if (entity.IsSchema || entity.GtsId != null) + { + id = entity.GtsId?.Id; + } + else + { + // Anonymous instance: use value from selected entity field (GetFieldValue trims; strips gts:// only for $id) + id = entity.SelectedEntityField != null ? GetFieldValue(json, entity.SelectedEntityField) : null; + } + + return new ExtractResult( + Id: id, + SchemaId: string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, + SelectedEntityField: entity.SelectedEntityField, + SelectedSchemaIdField: entity.SelectedSchemaIdField, + entity.IsSchema); + } + + /// + /// Extracts ID if the root is a JSON object; otherwise returns a result with null Id. + /// + /// Root JSON node (object, array, or value). + /// Optional; uses default property names if null. + /// ExtractResult with Id and SchemaId when node is an object; otherwise null Id. + public static ExtractResult ExtractId(JsonNode? node, GtsExtractOptions? options = null) + { + if (node is JsonObject obj) + return ExtractId(obj, options); + + return new ExtractResult(null, null, null, null, false); + } + + /// + /// Extracts ID from a JSON object element (e.g. from JsonDocument.RootElement). + /// + /// JSON element (must be Object). + /// Optional; uses default property names if null. + /// ExtractResult with Id, SchemaId, and field metadata. + /// Thrown when element is not ValueKind.Object. + public static ExtractResult ExtractId(JsonElement element, GtsExtractOptions? options = null) + { + if (element.ValueKind != JsonValueKind.Object) + throw new ArgumentException("JSON root must be an object.", nameof(element)); + + var node = JsonNode.Parse(element.GetRawText()); + if (node is JsonObject obj) + return ExtractId(obj, options); + + return new ExtractResult(null, null, null, null, false); + } + + /// + /// Builds a GtsJsonEntity with primary ID, schema ID, and all GTS references in the tree. + /// + /// Root JSON object. + /// Optional; uses default property names if null. + /// GtsJsonEntity with GtsId, SchemaId, Content, and GtsRefs. + public static GtsJsonEntity ExtractEntity(JsonObject json, GtsExtractOptions? options = null) + { + options ??= GtsExtractOptions.Default; + + return ExtractEntityInternal(json, options); + } + + /// + /// Walks the JSON tree and returns every string that is a valid GTS ID, with its path. + /// + /// Root JSON node to scan. + /// List of GtsReference (id and source path), deduplicated by id+path. + public static IReadOnlyList ExtractReferences(JsonNode? node) + { + var refs = new List(); + var seen = new HashSet(StringComparer.Ordinal); + WalkAndCollectRefs(node, "", refs, seen); + return refs; + } + + private static GtsJsonEntity ExtractEntityInternal(JsonObject json, GtsExtractOptions options) + { + var isSchema = IsJsonSchema(json); + var (selectedEntityField, entityIdValue) = FirstNonEmptyField(json, options.EntityIdPropertyNames); + + string schemaId; + string? selectedSchemaIdField = null; + + if (isSchema) + { + // Derived schema: chain has more than one ~ + if (!string.IsNullOrEmpty(entityIdValue) && IsValidGtsId(entityIdValue) && entityIdValue.EndsWith('~')) + { + var firstTilde = entityIdValue.IndexOf('~'); + if (firstTilde > 0) + { + var afterFirst = entityIdValue.AsSpan(firstTilde + 1); + var secondTilde = afterFirst.IndexOf('~'); + if (secondTilde >= 0) + { + selectedSchemaIdField = selectedEntityField; + schemaId = entityIdValue[..(firstTilde + 1)]; + return BuildEntity(json, options, isSchema, entityIdValue, selectedEntityField, schemaId, selectedSchemaIdField); + } + } + } + var schemaValue = GetFieldValue(json, "$schema"); + if (!string.IsNullOrEmpty(schemaValue)) + { + selectedSchemaIdField = "$schema"; + return BuildEntity(json, options, isSchema, entityIdValue, selectedEntityField, schemaValue, selectedSchemaIdField); + } + return BuildEntity(json, options, isSchema, entityIdValue, selectedEntityField, "", null); + } + else + { + // Instance: try entity ID chain first + if (!string.IsNullOrEmpty(entityIdValue) && IsValidGtsId(entityIdValue) && !entityIdValue.EndsWith('~')) + { + var lastTilde = entityIdValue.LastIndexOf('~'); + if (lastTilde > 0) + { + selectedSchemaIdField = selectedEntityField; + schemaId = entityIdValue[..(lastTilde + 1)]; + return BuildEntity(json, options, isSchema, entityIdValue, selectedEntityField, schemaId, selectedSchemaIdField); + } + } + var (schemaField, schemaValue) = FirstNonEmptyField(json, options.SchemaIdPropertyNames); + schemaId = schemaValue ?? ""; + selectedSchemaIdField = string.IsNullOrEmpty(schemaValue) ? null : schemaField; + return BuildEntity(json, options, isSchema, entityIdValue, selectedEntityField, schemaId, selectedSchemaIdField); + } + } + + private static GtsJsonEntity BuildEntity( + JsonObject json, + GtsExtractOptions options, + bool isSchema, + string? entityIdValue, + string? selectedEntityField, + string schemaId, + string? selectedSchemaIdField) + { + GtsId? gtsId = null; + if (isSchema) + { + if (!string.IsNullOrEmpty(entityIdValue) && IsValidGtsId(entityIdValue) && GtsId.TryParse(entityIdValue, out var parsed)) + gtsId = parsed; + } + else + { + if (!string.IsNullOrEmpty(entityIdValue) && IsValidGtsId(entityIdValue) && GtsId.TryParse(entityIdValue, out var parsed)) + { + gtsId = parsed; + if (string.IsNullOrEmpty(schemaId) && !string.IsNullOrEmpty(selectedEntityField)) + { + if (!entityIdValue.EndsWith('~')) + { + var lastTilde = entityIdValue.LastIndexOf('~'); + if (lastTilde > 0) + { + schemaId = entityIdValue[..(lastTilde + 1)]; + selectedSchemaIdField = selectedEntityField; + } + } + } + } + } + + var refs = ExtractReferences(json); + var label = gtsId != null ? gtsId.Id : ""; + + return new GtsJsonEntity( + gtsId, + schemaId, + selectedEntityField, + selectedSchemaIdField, + isSchema, + json, + refs, + label); + } + + private static bool IsJsonSchema(JsonObject json) + { + if (json.TryGetPropertyValue("$schema", out _)) return true; + if (json.TryGetPropertyValue("$$schema", out _)) return true; + return false; + } + + private static string? GetFieldValue(JsonObject json, string propertyName) + { + if (!json.TryGetPropertyValue(propertyName, out var node)) + return null; + + var s = node?.GetValue(); + if (string.IsNullOrWhiteSpace(s)) + return null; + + var trimmed = s!.Trim(); + if (trimmed.Length == 0) + return null; + + if ((propertyName == "$id" || propertyName == "$$id") && trimmed.StartsWith(GtsUriPrefix, StringComparison.Ordinal)) + trimmed = trimmed.Substring(GtsUriPrefix.Length); + + return trimmed; + } + + 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); + if (!string.IsNullOrEmpty(val)) + return (name, val); + } + + return (null, null); + } + + 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) + { + if (node == null) return; + + if (node is JsonValue value && value.GetValueKind() == JsonValueKind.String) + { + var str = value.GetValue(); + if (!string.IsNullOrEmpty(str) && str.StartsWith(GtsUriPrefix, StringComparison.Ordinal)) + str = str[GtsUriPrefix.Length..]; + + if (!string.IsNullOrEmpty(str) && IsValidGtsId(str)) + { + var sourcePath = string.IsNullOrEmpty(path) ? "root" : path; + var key = str + "|" + sourcePath; + if (seen.Add(key)) + refs.Add(new GtsReference(str, sourcePath)); + } + + return; + } + + if (node is JsonObject obj) + { + foreach (var (k, v) in obj) + { + var nextPath = string.IsNullOrEmpty(path) ? k : path + "." + k; + WalkAndCollectRefs(v, nextPath, refs, seen); + } + + return; + } + + if (node is JsonArray arr) + { + for (var i = 0; i < arr.Count; i++) + { + var nextPath = string.IsNullOrEmpty(path) ? "[" + i + "]" : path + "[" + i + "]"; + WalkAndCollectRefs(arr[i], nextPath, refs, seen); + } + } + } +} diff --git a/Gts/Extraction/GtsReference.cs b/Gts/Extraction/GtsReference.cs new file mode 100644 index 0000000..308ad50 --- /dev/null +++ b/Gts/Extraction/GtsReference.cs @@ -0,0 +1,8 @@ +namespace Gts.Extraction; + +/// +/// A GTS ID found somewhere in a JSON tree, with its JSON path (e.g. "$id", "properties.x.$ref"). +/// +/// The GTS identifier string. +/// JSON path where the ID was found; "root" for root-level. +public sealed record GtsReference(string Id, string SourcePath); diff --git a/Gts/Gts.csproj b/Gts/Gts.csproj new file mode 100644 index 0000000..21d472c --- /dev/null +++ b/Gts/Gts.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/Gts/GtsId.cs b/Gts/GtsId.cs new file mode 100644 index 0000000..24bb025 --- /dev/null +++ b/Gts/GtsId.cs @@ -0,0 +1,232 @@ +using Gts.Parsing; +using Gts.Utils; +using Pidgin; +using ParseException = Gts.Parsing.ParseException; + +namespace Gts; + +/// +/// A validated GTS identifier. +/// +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!; + } + + 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); + } + + /// Parses a GTS pattern ID; throws on failure. + public static GtsId ParsePattern(string pattern) + { + var parseResult = TryParsePatternInternal(pattern, out GtsId? result); + + if (parseResult) + { + return result!; + } + + throw new ParseException(parseResult); + } + + /// Attempts to parse a GTS pattern ID without throwing. + public static ParseResult TryParsePattern(string? pattern, out GtsId? result) + { + return TryParsePatternInternal(pattern, out result); + } + + private static ParseResult TryParseInternal(string? id, out GtsId? result) + { + if (id is null) + { + result = null; + return ParseResult.ArgumentIsNull; + } + + var (parseResult, isType) = id.EndsWith("~") + ? (Parsers.GtsTypeId.Parse(id), true) + : (Parsers.GtsInstanceId.Parse(id), false); + + if (!parseResult.Success) + { + // TODO: add errors to the result + result = null; + return new ParseResult(); + } + + var segments = parseResult.Value + .Select(MapSegment); + + result = new GtsId(id, new List(segments)) + { + IsType = isType, + IsInstance = !isType, + IsPattern = false + }; + + return ParseResult.Success; + } + + private static ParseResult TryParsePatternInternal(string? pattern, out GtsId? result) + { + if (pattern is null) + { + 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(); + } + + var segments = parseResult.Value + .Select(MapSegment); + + result = new GtsId(pattern, new List(segments)) + { + IsPattern = true + }; + + return ParseResult.Success; + } + + private static GtsIdSegment MapSegment(Parsers.SegmentInfo s) + { + return new GtsIdSegment( + 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) + { + 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('*')) + return false; + + return MatchSegments(pattern.Segments, Segments, false); + } + + /// + /// 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!); + } + + 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; + + var patternList = patternSegs as IList ?? patternSegs.ToList(); + var candidateList = candidateSegs as IList ?? candidateSegs.ToList(); + + for (var i = 0; i < patternList.Count; i++) + { + 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; + } + + /// + /// Generates a deterministic UUID v5 from this GTS identifier using the GTS namespace. + /// + public Guid ToGuid() + => GuidUtils.Create(GuidUtils.GtsNamespace, Id); + + /// + public override bool Equals(object? obj) + => obj is GtsId id && string.Equals(Id, id.Id, StringComparison.Ordinal); + + /// + public override int GetHashCode() + => StringComparer.Ordinal.GetHashCode(Id); + + /// + /// Returns the canonical identifier string (same as ). + /// + public override string ToString() => Id; +} diff --git a/Gts/GtsIdSegment.cs b/Gts/GtsIdSegment.cs new file mode 100644 index 0000000..c36b27e --- /dev/null +++ b/Gts/GtsIdSegment.cs @@ -0,0 +1,117 @@ +using System.Text; + +namespace Gts; + +/// +/// Represents a parsed segment of a GTS identifier. +/// +public sealed class GtsIdSegment +{ + /// Creates a segment with the given vendor, package, namespace, type, and version. + internal GtsIdSegment( + //string? segment, + string? vendor, + string? package, + string? ns, + string? type, + int? versionMajor, + int? versionMinor, + bool isType, + bool isWildcard) + { + //Segment = segment; + Vendor = vendor; + Package = package; + Namespace = ns; + Type = type; + VersionMajor = versionMajor; + VersionMinor = versionMinor; + IsType = isType; + IsWildcard = isWildcard; + } + + /// Parameterless constructor for internal use. + 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; } + + /// + /// Returns the segment in GTS segment form. + /// + public override string ToString() + { + var sb = new StringBuilder( + (Vendor?.Length ?? 0) + (Package?.Length ?? 0) + (Namespace?.Length ?? 0) + (Type?.Length ?? 0) + + (VersionMajor.HasValue ? 2 : 0) // rough estimation for version + + (VersionMinor.HasValue ? 2 : 0) + ); + + if (Vendor is not null) + { + sb.Append(Vendor); + sb.Append('.'); + } + + if (Package is not null) + { + sb.Append(Package); + sb.Append('.'); + } + + if (Namespace is not null) + { + sb.Append(Namespace); + sb.Append('.'); + } + + if (Type is not null) + { + sb.Append(Type); + sb.Append('.'); + } + + if (VersionMajor.HasValue) + { + sb.Append('v'); + sb.Append(VersionMajor.Value); + + if (VersionMinor.HasValue) + { + sb.Append('.'); + sb.Append(VersionMinor.Value); + } + } + else + { + sb.Append('*'); + } + + return sb.ToString(); + } +} diff --git a/Gts/Parsing/ParseException.cs b/Gts/Parsing/ParseException.cs new file mode 100644 index 0000000..05e5986 --- /dev/null +++ b/Gts/Parsing/ParseException.cs @@ -0,0 +1,14 @@ +namespace Gts.Parsing; + +/// Thrown when GTS ID or pattern parsing fails. +public class ParseException : Exception +{ + /// The parse result describing the failure. + public ParseResult ParseResult { get; } + + /// Creates an exception with the given parse result. + public ParseException(ParseResult parseResult) + { + ParseResult = parseResult; + } +} \ No newline at end of file diff --git a/Gts/Parsing/ParseResult.cs b/Gts/Parsing/ParseResult.cs new file mode 100644 index 0000000..546d54c --- /dev/null +++ b/Gts/Parsing/ParseResult.cs @@ -0,0 +1,16 @@ +namespace Gts.Parsing; + +/// Result of a GTS parse attempt; use implicit conversion to bool for success check. +public sealed class ParseResult +{ + /// Indicates successful parse. + 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) + { + return result == Success; + } +} diff --git a/Gts/Parsing/Parsers.cs b/Gts/Parsing/Parsers.cs new file mode 100644 index 0000000..f8f73d9 --- /dev/null +++ b/Gts/Parsing/Parsers.cs @@ -0,0 +1,180 @@ +using System.Collections; +using Pidgin; +using static Pidgin.Parser; +using static Pidgin.Parser; + +namespace Gts.Parsing; + +/// Internal parser definitions for GTS type, instance, and pattern identifiers. +internal static class Parsers +{ + internal static readonly Parser Dot = + Char('.').Labelled("."); + internal static readonly Parser Tilde = + Char('~').Labelled("~"); + + internal static readonly Parser Wildcard = + String("*").Labelled(nameof(Wildcard)); + + internal static readonly Parser V = + Char('v').Labelled(nameof(V)); + + internal static readonly Parser VersionNumber = + Num.Labelled(nameof(VersionNumber)); + internal static readonly Parser VersionMajor = + V.Then(VersionNumber).Labelled(nameof(VersionMajor)); + internal static readonly Parser VersionMinor = + VersionNumber.Labelled(nameof(VersionMinor)); + + internal static readonly Parser VersionFull = + VersionMajor.Then( + Dot.Then(VersionMinor).Optional(), + (major, minor) => new VersionInfo(major, minor.HasValue ? minor.Value : null)) + .Labelled(nameof(VersionFull)); + + internal static readonly Parser VersionFullString = + VersionFull.Select(v => v.ToString()) + .Labelled(nameof(VersionFullString)); + + // internal static readonly Parser VersionSuffix = + // Dot.Then(VersionFull) + // .Optional().Select(v => v.HasValue ? (VersionInfo?)v.Value : null); + + private static readonly Parser IdentifierStart = + Token(c => c == '_' || c is >= 'a' and <= 'z'); + + private static readonly Parser IdentifierRest = + Token(c => c == '_' || c is >= 'a' and <= 'z' || c is >= '0' and <= '9'); + + internal static readonly Parser Identifier = + IdentifierStart.Then(IdentifierRest.Many(), (first, rest) => first + string.Concat(rest)) + .Labelled(nameof(Identifier)); + + 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 = + Identifier.Labelled(nameof(Package)); + internal static readonly Parser Namespace = + 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)) + .Then(Namespace.Before(Dot), (t, nspace) => (t.vendor, t.package, nspace)) + .Then(Type.Before(Dot), (t, type) => (t.vendor, t.package, t.nspace, type)) + .Then(VersionFull, + (t, version) => new SegmentInfo( + t.vendor, + t.package, + t.nspace, + t.type, + version, + false)) + .Labelled(nameof(Segment)); + + internal static readonly Parser Pattern = + Vendor.Before(Dot).Optional() + .Then(Package.Before(Dot).Optional(), (vendor, package) => (vendor, package)) + .Then(Namespace.Before(Dot).Optional(), (t, nspace) => (t.vendor, t.package, nspace)) + .Then(Type.Before(Dot).Optional(), (t, type) => (t.vendor, t.package, t.nspace, type)) + .Then(Wildcard.Or(VersionFullString), + (t, version) => new SegmentInfo( + t.vendor.GetValueOrDefault(), + t.package.GetValueOrDefault(), + t.nspace.GetValueOrDefault(), + t.type.GetValueOrDefault(), + version is "*" + ? null + : VersionInfo.FromString(version), + version is "*")) + .Labelled(nameof(Pattern)); + + // internal static readonly Parser TypeSuffix = + // Try(Tilde.Then(Segment)).AtLeastOnce().Select(segments => new TypeInfo(segments)) + // .Or(Tilde.Select(_ => new TypeInfo([]))); + + // internal static readonly Parser InstanceSuffix = + // Tilde.Then(Segment) + // .Then(Tilde.Then(Segment).Many(), (head, tail) => new InstanceInfo(head, tail)); + + internal static readonly Parser GtsTypeId = + GtsPrefix.Then(Dot) + .Then(Segment.SeparatedAndTerminatedAtLeastOnce(Tilde), + (_, segments) => new IdentifierInfo(IdentifierKind.Type, segments)) + .Before(End); + + internal static readonly Parser GtsInstanceId = + GtsPrefix.Then(Dot) + .Then(Segment.SeparatedAtLeastOnce(Tilde), + (_, segments) => new IdentifierInfo(IdentifierKind.Instance, segments)) + .Before(End); + + internal static readonly Parser GtsPattern = + GtsPrefix.Then(Dot) + .Then(Pattern.SeparatedAndOptionallyTerminatedAtLeastOnce(Tilde), + (_, segments) => new IdentifierInfo(IdentifierKind.Pattern, segments)) + .Before(End); + + /// Major and optional minor version from a GTS segment. + internal record struct VersionInfo( + int Major, + int? Minor) + { + /// + public override string ToString() => Minor == null + ? $"v{Major}" + : $"v{Major}.{Minor}"; + + /// Parses a version string (e.g. "v1" or "v1.0"). + public static VersionInfo? FromString(string str) + { + var parts = str.Substring(1).Split('.'); + return parts.Length == 1 + ? new VersionInfo(int.Parse(parts[0]), null) + : new VersionInfo(int.Parse(parts[0]), int.Parse(parts[1])); + } + } + + /// Parsed segment: vendor.package.namespace.type.version or wildcard. + internal record struct SegmentInfo( + string? Vendor, + string? Package, + string? Namespace, + string? Type, + VersionInfo? Version, + bool IsWildcard); + + /// Kind of GTS identifier (type, instance, or pattern). + internal enum IdentifierKind + { + Type, + Instance, + Pattern, + } + + /// Parsed identifier with kind and segment list. + internal record struct IdentifierInfo( + IdentifierKind Kind, IEnumerable Segments) : IEnumerable + { + /// True if this is a type ID. + public bool IsType => Kind == IdentifierKind.Type; + /// True if this is an instance ID. + public bool IsInstance => Kind == IdentifierKind.Instance; + /// True if this is a pattern. + public bool IsPattern => Kind == IdentifierKind.Pattern; + + /// + public IEnumerator GetEnumerator() => Segments.GetEnumerator(); + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/Gts/Properties/AssemblyInfo.cs b/Gts/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4aac24a --- /dev/null +++ b/Gts/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Gts.Tests")] diff --git a/Gts/Utils/GuidUtils.cs b/Gts/Utils/GuidUtils.cs new file mode 100644 index 0000000..8b4c081 --- /dev/null +++ b/Gts/Utils/GuidUtils.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Gts.Utils; + +/// +/// UUID version 5 (SHA-1 hash based) as per RFC 4122. +/// 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). + /// + /// RFC 4122 namespace UUID. + /// Name string to hash (e.g. GTS ID). + /// The generated UUID v5. + internal static Guid Create(Guid namespaceId, string name) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentNullException(nameof(name)); + + byte[] namespaceBytes = namespaceId.ToByteArray(); + SwapByteOrder(namespaceBytes); + + byte[] nameBytes = Encoding.UTF8.GetBytes(name); + + byte[] hashInput = new byte[namespaceBytes.Length + nameBytes.Length]; + Buffer.BlockCopy(namespaceBytes, 0, hashInput, 0, namespaceBytes.Length); + Buffer.BlockCopy(nameBytes, 0, hashInput, namespaceBytes.Length, nameBytes.Length); + + byte[] hash = SHA1.HashData(hashInput); + + // Set version (5) and variant (RFC 4122) + byte[] result = new byte[16]; + Buffer.BlockCopy(hash, 0, result, 0, 16); + result[6] = (byte)((result[6] & 0x0F) | 0x50); + result[8] = (byte)((result[8] & 0x3F) | 0x80); + + SwapByteOrder(result); + return new Guid(result); + } + + private static void SwapByteOrder(byte[] guid) + { + if (guid.Length != 16) return; + Swap(guid, 0, 3); + Swap(guid, 1, 2); + Swap(guid, 4, 5); + Swap(guid, 6, 7); + } + + private static void Swap(byte[] arr, int i, int j) + { + (arr[i], arr[j]) = (arr[j], arr[i]); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ca21a1 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +> Status: initial draft v0.1, not for production use + +# GTS .NET Library + +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. + +## Roadmap + +Featureset: + +- [x] **OP#1 - ID Validation**: Verify identifier syntax using regex patterns +- [x] **OP#2 - ID Extraction**: Fetch identifiers from JSON objects or JSON Schema documents +- [x] **OP#3 - ID Parsing**: Decompose identifiers into constituent parts (vendor, package, namespace, type, version, etc.) +- [x] **OP#4 - ID Pattern Matching**: Match identifiers against patterns containing wildcards +- [x] **OP#5 - ID to UUID Mapping**: Generate deterministic UUIDs from GTS identifiers +- [x] **OP#6 - Instance Validation**: Validate object instances against their corresponding schemas +- [ ] **OP#7 - Relationship Resolution**: Load all schemas and instances, resolve inter-dependencies, and detect broken references +- [ ] **OP#8 - Compatibility Checking**: Verify that schemas with different MINOR versions are compatible +- [ ] **OP#8.1 - Backward compatibility checking** +- [ ] **OP#8.2 - Forward compatibility checking** +- [ ] **OP#8.3 - Full compatibility checking** +- [ ] **OP#9 - Version Casting**: Transform instances between compatible MINOR versions +- [ ] **OP#10 - Query Execution**: Filter identifier collections using the GTS query language +- [ ] **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) +- [ ] **OP#12 - Schema Validation**: Validate schema against its precedent schema + +## Installation + +```bash +#TODO: NuGet packages +``` + +## Usage + +### Library + +```csharp +using Gts; +using Gts.Extraction; +``` + +### OP#2 - ID Extraction + +Extract entity and schema IDs from JSON objects (and JSON Schema documents). Uses `System.Text.Json` (`JsonObject` / `JsonNode`). + +- **ExtractId(JsonObject, GtsExtractOptions?)** — returns `ExtractResult` with `Id`, `SchemaId`, which fields were used, and `IsSchema`. `Id` is null when no valid ID is found. +- **ExtractId(JsonNode?)** / **ExtractId(JsonElement)** — overloads for different JSON sources. +- **ExtractEntity(JsonObject, …)** — returns `GtsJsonEntity` with parsed `GtsId`, schema ID, and all **GtsRefs** (every GTS ID in the tree with path). +- **ExtractReferences(JsonNode?)** — walks the tree and returns all GTS IDs with their JSON paths. +- **GtsExtractOptions.Default** — default entity fields. + +```csharp +var node = JsonNode.Parse("""{ "gtsId": "gts.acme.order.ns.invoice.v1.0", "name": "Order 1" }"""); + +var result = GtsExtract.ExtractId(node.AsObject()); +// result.Id, result.SchemaId, result.SelectedEntityField, result.IsSchema + +var entity = GtsExtract.ExtractEntity(node.AsObject()); +// entity.GtsId, entity.GtsRefs (all GTS IDs + paths) + +var refs = GtsExtract.ExtractReferences(node); +``` + +### OP#3 - ID Parsing + +Decompose GTS identifiers into constituent parts (vendor, package, namespace, type, version, etc.). + +- **Parse** a type ID (trailing `~`) or instance ID; **TryParse** for safe parsing without exceptions. +- **ParsePattern** / **TryParsePattern** for patterns that may end with a wildcard (`.*`). + +```csharp +// Parsing +var id = GtsId.Parse("gts.acme.order.ns.invoice.v1~"); + +// Safe parsing +if (GtsId.TryParse("gts.vendor.pkg.ns.type.v1.0", out var id)) + // do something + +// Pattern (for matching) +var pattern = GtsId.ParsePattern("gts.acme.order.*"); + +// Safe pattern parsing +if (GtsId.TryParsePattern("gts.acme.order.*", out var pattern)) + // do something +``` + +### OP#4 - ID Pattern Matching + +Match identifiers against patterns containing wildcards. + +- **Matches(GtsId pattern)** — match this ID against a parsed pattern. +- **Matches(string pattern)** — match this ID against a pattern string. + +```csharp +var candidate = GtsId.Parse("gts.acme.order.ns.invoice.v1.0"); + +// Match against pattern (parsed) +var pattern = GtsId.ParsePattern("gts.acme.order.*"); +candidate.Matches(pattern); // true + +// Match against pattern string +candidate.Matches("gts.acme.order.*"); // true +candidate.Matches("gts.acme.order.ns.*"); // true +candidate.Matches("gts.other.*"); // false + +// Exact match (no wildcard) +candidate.Matches("gts.acme.order.ns.invoice.v1.0"); // true +``` + +### OP#5 - ID to UUID Mapping + +Generate a deterministic UUID v5 from a GTS identifier. The same ID always yields the same UUID (RFC 4122, namespace + name hashed). + +- **ToGuid()** — returns a `Guid` for this GTS ID using the standard GTS namespace. + +```csharp +var id = GtsId.Parse("gts.acme.order.ns.invoice.v1.0"); +Guid uuid = id.ToGuid(); // deterministic: same ID → same UUID every time +``` + +### OP#6 - Instance Validation + +Validate stored JSON instances against Draft 07 JSON Schemas registered in the same `GtsRegistry`, including `gts://` `$ref` between schemas and GTS `$$id` / `$$ref` / `$$schema` keywords on schema documents. + +- **ValidateInstanceAsync(instanceId)** — loads the instance (by GTS instance id or opaque id such as a UUID), resolves its type (chained instance id or `type` field), fetches the schema from the store, and evaluates the instance with [JsonSchema.Net](https://www.nuget.org/packages/JsonSchema.Net). Returns `GtsInstanceValidationResult` with `Ok`, `Id`, `FailureReason`, and optional `SchemaErrors`. + +```csharp +using Gts.Extraction; +using Gts.Store; + +var registry = GtsRegistry.InMemory(new GtsRegistryConfig(false)); +// Save schemas (JSON Schema with $id or $$id) and instances via SaveAsync(GtsJsonEntity.ExtractEntity(...)) +var result = await registry.ValidateInstanceAsync("gts.vendor.pkg.ns.type.v1~x._.myinst.v1"); +// result.Ok, result.FailureReason, result.SchemaErrors +``` + diff --git a/global.json b/global.json new file mode 100644 index 0000000..b5b37b6 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/gts-dotnet.sln b/gts-dotnet.sln new file mode 100644 index 0000000..59bec0b --- /dev/null +++ b/gts-dotnet.sln @@ -0,0 +1,46 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gts", "Gts\Gts.csproj", "{B76DC7E4-8446-47E0-8F09-035AB2619449}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gts.Tests", "Gts.Tests\Gts.Tests.csproj", "{D03BF2F9-7424-46EC-9337-D65E5D9006D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gts.Store", "Gts.Store\Gts.Store.csproj", "{BB887F91-1352-4726-B555-CB1B77B33B34}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gts.Server", "Gts.Server\Gts.Server.csproj", "{C1A2B3C4-D5E6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gts.Application", "Gts.Application\Gts.Application.csproj", "{A1B2C3D4-E5F6-4789-A012-345678901234}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gts.Cli", "Gts.Cli\Gts.Cli.csproj", "{B2C3D4E5-F6A7-4890-B123-456789012345}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B76DC7E4-8446-47E0-8F09-035AB2619449}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B76DC7E4-8446-47E0-8F09-035AB2619449}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B76DC7E4-8446-47E0-8F09-035AB2619449}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B76DC7E4-8446-47E0-8F09-035AB2619449}.Release|Any CPU.Build.0 = Release|Any CPU + {D03BF2F9-7424-46EC-9337-D65E5D9006D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D03BF2F9-7424-46EC-9337-D65E5D9006D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D03BF2F9-7424-46EC-9337-D65E5D9006D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D03BF2F9-7424-46EC-9337-D65E5D9006D9}.Release|Any CPU.Build.0 = Release|Any CPU + {BB887F91-1352-4726-B555-CB1B77B33B34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB887F91-1352-4726-B555-CB1B77B33B34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB887F91-1352-4726-B555-CB1B77B33B34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB887F91-1352-4726-B555-CB1B77B33B34}.Release|Any CPU.Build.0 = Release|Any CPU + {C1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1A2B3C4-D5E6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-B123-456789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-B123-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-B123-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-B123-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal