diff --git a/.gts-spec-version b/.gts-spec-version
index b0d2e47..6cf9fa5 100644
--- a/.gts-spec-version
+++ b/.gts-spec-version
@@ -1 +1 @@
-v0.14.2
+v0.14.4
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..b9ad361
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,12 @@
+
+
+
+
+ net8.0
+ enable
+ enable
+ 0.1.0
+
+
+
diff --git a/Gts.Application/Gts.Application.csproj b/Gts.Application/Gts.Application.csproj
index 0ae542f..4cbf0be 100644
--- a/Gts.Application/Gts.Application.csproj
+++ b/Gts.Application/Gts.Application.csproj
@@ -1,9 +1,6 @@
- net8.0
- enable
- enable
Gts.Application
diff --git a/Gts.Application/GtsEntityOperations.cs b/Gts.Application/GtsEntityOperations.cs
index 810ef3a..f625a22 100644
--- a/Gts.Application/GtsEntityOperations.cs
+++ b/Gts.Application/GtsEntityOperations.cs
@@ -7,13 +7,14 @@ namespace Gts.Application;
/// Adds entities to a registry with the same rules as the GTS HTTP API.
public static class GtsEntityOperations
{
- public sealed record AddResult(bool Ok, string Id, string? SchemaId, bool IsSchema, string? Error);
+ public sealed record AddResult(bool Ok, string Id, string? SchemaId, bool IsSchema, string? Error, bool Conflict = false);
public static async Task TryAddAsync(
GtsRegistry registry,
JsonObject body,
bool validate,
GtsExtractOptions? extractOptions = null,
+ GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -24,7 +25,7 @@ public static async Task TryAddAsync(
if (!entity.IsSchema)
{
if (string.IsNullOrEmpty(entity.SelectedEntityField))
- return new AddResult(false, "", null, false, "Instance must have an id field");
+ return new AddResult(false, "", null, false, "Unable to detect GTS ID in instance entity");
if (entity.GtsId is { IsInstance: true } instanceId && instanceId.Segments.Count == 1)
return new AddResult(false, instanceId.Id, null, false, "Single-segment instance IDs are not allowed");
}
@@ -39,11 +40,17 @@ public static async Task TryAddAsync(
var rawId = body.TryGetPropertyValue("$id", out var idn) && idn is JsonValue idValue && idValue.TryGetValue(out var id)
? id
: null;
- if (validate && !string.IsNullOrEmpty(rawId) && rawId.StartsWith("gts.", StringComparison.Ordinal) &&
- !rawId.StartsWith("gts://", StringComparison.Ordinal))
+ if (validate && !string.IsNullOrEmpty(rawId) && rawId.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) &&
+ !rawId.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal))
return new AddResult(false, "", null, true, "Schema $id must use gts:// URI format, not plain gts. prefix");
- if (!TryGetSupportedDialect(body, out var dialectError))
+ if (!GtsTypeSchema.TryGetSupportedDialect(body, out _, out var dialectError))
return new AddResult(false, entity.GtsId?.Id ?? "", null, true, dialectError);
+ var keywordErrors = GtsSchemaKeywordValidator.Validate(body);
+ if (keywordErrors.Count > 0)
+ return new AddResult(false, entity.GtsId?.Id ?? "", null, true, string.Join("; ", keywordErrors));
+ var refErrors = GtsRefValidator.ValidatePatterns(body, entity.GtsId?.Id ?? "");
+ if (refErrors.Count > 0)
+ return new AddResult(false, entity.GtsId?.Id ?? "", null, true, "x-gts-ref validation failed: " + string.Join("; ", refErrors));
}
try
@@ -55,59 +62,129 @@ public static async Task TryAddAsync(
return new AddResult(false, "", null, entity.IsSchema, ex.Message);
}
+ GtsJsonEntity? existing = entity.GtsId is not null
+ ? await registry.GetAsync(entity.GtsId).ConfigureAwait(false)
+ : !string.IsNullOrEmpty(extract.Id)
+ ? await registry.GetByInstanceIdAsync(extract.Id).ConfigureAwait(false)
+ : null;
+ if (existing is not null)
+ {
+ if (JsonNode.DeepEquals(existing.Content, entity.Content) && !validate)
+ return new AddResult(true, extract.Id ?? entity.GtsId?.Id ?? "", entity.SchemaId, entity.IsSchema, null);
+ if (!JsonNode.DeepEquals(existing.Content, entity.Content))
+ return new AddResult(false, extract.Id ?? entity.GtsId?.Id ?? "", entity.SchemaId, entity.IsSchema,
+ "Entity already exists with different content", true);
+ }
+
if (entity.IsSchema && entity.GtsId is not null)
{
if (validate)
{
- var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken)
+ var registered = await registry.SnapshotForReadAsync().ConfigureAwait(false);
+ var constraintErrors = GtsRefValidator.ValidateConstraints(entity.Content, entity.GtsId.Id, registered, refValidationMode);
+ if (constraintErrors.Count > 0)
+ return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, "x-gts-ref validation failed: " + string.Join("; ", constraintErrors));
+ var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode)
.ConfigureAwait(false);
if (!schemaVr.Ok)
{
var msg = schemaVr.Errors is { Count: > 0 }
? string.Join("; ", schemaVr.Errors)
- : (schemaVr.FailureReason ?? "Schema validation failed");
+ : (schemaVr.FailureReason?.ToWire() ?? "Schema validation failed");
return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, msg);
}
}
- await registry.SaveAsync(entity).ConfigureAwait(false);
+ if (await registry.TrySaveAsync(entity).ConfigureAwait(false) == GtsSaveOutcome.Conflict)
+ return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, "Entity already exists with different content", true);
var schemaIdOut = entity.GtsId.Id;
return new AddResult(true, schemaIdOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, true, null);
}
- await registry.SaveAsync(entity).ConfigureAwait(false);
-
- if (validate && !entity.IsSchema && entity.GtsId is not null)
+ if (validate && !entity.IsSchema)
{
- var vr = await registry.ValidateInstanceAsync(entity.GtsId.Id, cancellationToken).ConfigureAwait(false);
- if (!vr.Ok)
- return new AddResult(false, entity.GtsId.Id, entity.SchemaId, false, vr.FailureReason ?? "Validation failed");
+ if (string.IsNullOrEmpty(entity.SchemaId) || !GtsId.TryParse(entity.SchemaId, out var schemaId) || schemaId is null || !schemaId.IsType)
+ return new AddResult(false, entity.GtsId?.Id ?? extract.Id ?? "", entity.SchemaId, false, "Unable to determine instance type");
+ var validation = await registry.ValidateJsonAsync(entity.Content, schemaId, entity.GtsId?.Id ?? extract.Id, cancellationToken, refValidationMode)
+ .ConfigureAwait(false);
+ if (!validation.Ok)
+ {
+ var error = validation.SchemaErrors is { Count: > 0 }
+ ? string.Join("; ", validation.SchemaErrors)
+ : validation.FailureReason?.ToWire() ?? "Validation failed";
+ return new AddResult(false, entity.GtsId?.Id ?? extract.Id ?? "", entity.SchemaId, false, error);
+ }
}
var idOut = entity.GtsId?.Id ?? extract.Id ?? "";
+ if (await registry.TrySaveAsync(entity).ConfigureAwait(false) == GtsSaveOutcome.Conflict)
+ return new AddResult(false, idOut, entity.SchemaId, entity.IsSchema, "Entity already exists with different content", true);
return new AddResult(true, idOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, entity.IsSchema, null);
}
- private static bool TryGetSupportedDialect(JsonObject body, out string? error)
+ ///
+ /// Runs the structural checks for a batch Type Schema entry (canonical $schema/$id, dialect, GTS
+ /// keyword placement, $ref format and x-gts-ref patterns) and builds the entity WITHOUT touching the
+ /// store. Returns the entity on success or an error message. Mirrors the schema branch of
+ /// so a batch entry is checked exactly like a single POST /entities schema.
+ ///
+ public static (GtsJsonEntity? Entity, string? Error) PrepareSchema(JsonObject body, GtsExtractOptions? extractOptions = null)
{
- error = null;
- if (!body.TryGetPropertyValue("$schema", out var node) || node is not JsonValue value ||
- !value.TryGetValue(out var dialect) || string.IsNullOrEmpty(dialect))
+ var opt = extractOptions ?? GtsExtractOptions.Default;
+ var entity = GtsJsonEntity.ExtractEntity(body, opt);
+ if (!entity.IsSchema || entity.GtsId is null)
+ return (null, "Unable to detect GTS ID in schema");
+
+ var rawId = body.TryGetPropertyValue("$id", out var idn) && idn is JsonValue idValue && idValue.TryGetValue(out var id)
+ ? id
+ : null;
+ if (!string.IsNullOrEmpty(rawId) && rawId.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) &&
+ !rawId.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal))
+ return (null, "Schema $id must use gts:// URI format, not plain gts. prefix");
+ if (!GtsTypeSchema.TryGetSupportedDialect(body, out _, out var dialectError))
+ return (null, dialectError);
+ var keywordErrors = GtsSchemaKeywordValidator.Validate(body);
+ if (keywordErrors.Count > 0)
+ return (null, string.Join("; ", keywordErrors));
+ var refErrors = GtsRefValidator.ValidatePatterns(body, entity.GtsId.Id);
+ if (refErrors.Count > 0)
+ return (null, "x-gts-ref validation failed: " + string.Join("; ", refErrors));
+ try
{
- error = "Schema must contain a supported $schema dialect";
- return false;
+ GtsSchemaRefFormatValidator.ValidateRefs(body);
}
+ catch (Exception ex)
+ {
+ return (null, ex.Message);
+ }
+ return (entity, null);
+ }
- if (dialect is "http://json-schema.org/draft-07/schema#" or
- "https://json-schema.org/draft-07/schema#" or
- "https://json-schema.org/draft/2019-09/schema" or
- "https://json-schema.org/draft/2019-09/schema#" or
- "https://json-schema.org/draft/2020-12/schema" or
- "https://json-schema.org/draft/2020-12/schema#")
- return true;
-
- error = $"Unsupported JSON Schema dialect: {dialect}";
- return false;
+ ///
+ /// Runs the semantic validation of a staged schema (x-gts-ref existence, derivation/traits) against the
+ /// current staged+committed set, returning an error message on failure or null on success. Does not
+ /// mutate the store: the caller commits the staged entry on success or discards it on failure.
+ ///
+ public static async Task ValidateStagedSchemaAsync(
+ GtsRegistry registry,
+ GtsJsonEntity entity,
+ GtsRefValidationMode refValidationMode,
+ CancellationToken cancellationToken = default,
+ string? stagingSessionId = null)
+ {
+ if (entity.GtsId is null)
+ return "Unable to detect GTS ID in schema";
+ var registered = await registry.SnapshotForReadAsync(stagingSessionId).ConfigureAwait(false);
+ var constraintErrors = GtsRefValidator.ValidateConstraints(entity.Content, entity.GtsId.Id, registered, refValidationMode);
+ if (constraintErrors.Count > 0)
+ return "x-gts-ref validation failed: " + string.Join("; ", constraintErrors);
+ var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode, stagingSessionId)
+ .ConfigureAwait(false);
+ if (!schemaVr.Ok)
+ return schemaVr.Errors is { Count: > 0 }
+ ? string.Join("; ", schemaVr.Errors)
+ : (schemaVr.FailureReason?.ToWire() ?? "Schema validation failed");
+ return null;
}
}
diff --git a/Gts.Application/GtsHttpApiExtensions.cs b/Gts.Application/GtsHttpApiExtensions.cs
index a122431..3448a8f 100644
--- a/Gts.Application/GtsHttpApiExtensions.cs
+++ b/Gts.Application/GtsHttpApiExtensions.cs
@@ -1,16 +1,16 @@
using System.Text.Json;
using System.Text.Json.Nodes;
-using System.Text.RegularExpressions;
using Gts;
using Gts.Extraction;
using Gts.Store;
+using Microsoft.AspNetCore.Diagnostics;
namespace Gts.Application;
/// Maps the GTS HTTP API onto (shared by Gts.Server and CLI server).
-public static class GtsHttpApiExtensions
+public static partial class GtsHttpApiExtensions
{
- private static readonly GtsExtractOptions HttpExtractOptions = new()
+ internal static readonly GtsExtractOptions HttpExtractOptions = new()
{
AllowDoubleDollarKeywords = false,
EntityIdPropertyNames =
@@ -21,6 +21,16 @@ public static class GtsHttpApiExtensions
public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry registry)
{
+ app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
+ {
+ var error = context.Features.Get()?.Error;
+ var (status, message) = error is JsonException or BadHttpRequestException
+ ? (StatusCodes.Status422UnprocessableEntity, "Invalid JSON request body")
+ : (StatusCodes.Status500InternalServerError, "Internal server error");
+ context.Response.StatusCode = status;
+ await context.Response.WriteAsJsonAsync(new { ok = false, error = message }).ConfigureAwait(false);
+ }));
+
app.MapGet("/entities", async (int? limit) =>
{
var l = limit is >= 1 and <= 1000 ? limit.Value : 100;
@@ -46,24 +56,26 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi
id = e.GtsId?.Id ?? id,
schema_id = string.IsNullOrEmpty(e.SchemaId) ? null : e.SchemaId,
is_schema = e.IsSchema,
- content = JsonNode.Parse(e.Content.ToJsonString())
+ content = e.Content.DeepClone()
});
});
app.MapPost("/entities", async (HttpRequest req) =>
{
- var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase);
+ var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(req.Query["validation"], "true", StringComparison.OrdinalIgnoreCase);
+ var refValidationMode = NormalizeRefValidationMode(req.Query["gts-ref-validation"]);
var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false);
if (node is not JsonObject body)
return Results.Json(new { ok = false, error = "Body must be a JSON object", is_schema = false, is_type_schema = false },
statusCode: StatusCodes.Status422UnprocessableEntity);
- var result = await GtsEntityOperations.TryAddAsync(registry, body, validate, HttpExtractOptions).ConfigureAwait(false);
+ var result = await GtsEntityOperations.TryAddAsync(registry, body, validate, HttpExtractOptions, refValidationMode).ConfigureAwait(false);
if (!result.Ok)
return Results.Json(new { ok = false, error = result.Error, is_schema = result.IsSchema, is_type_schema = result.IsSchema },
- statusCode: StatusCodes.Status422UnprocessableEntity);
+ statusCode: result.Conflict ? StatusCodes.Status409Conflict : StatusCodes.Status422UnprocessableEntity);
- return Results.Json(new { ok = true, id = result.Id, schema_id = result.SchemaId, is_schema = result.IsSchema, is_type_schema = result.IsSchema });
+ return Results.Json(new { ok = true, id = result.Id, type_id = result.SchemaId, schema_id = result.SchemaId, is_schema = result.IsSchema, is_type_schema = result.IsSchema });
});
app.MapPost("/entities/bulk", async (HttpRequest req) =>
@@ -89,47 +101,153 @@ public static WebApplication MapGtsApi(this WebApplication app, GtsRegistry regi
app.MapPost("/type-schemas", async (HttpRequest req) =>
{
+ // ?validate / ?gts-ref-validation apply to every batch entry exactly
+ // as on POST /entities; a bogus gts-ref-validation is rejected before
+ // any entry is registered.
+ var validate = string.Equals(req.Query["validate"], "true", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(req.Query["validation"], "true", StringComparison.OrdinalIgnoreCase);
+ if (!GtsRefValidationModes.TryParse(req.Query["gts-ref-validation"].ToString(), out var refValidationMode))
+ return Results.Json(new { ok = false, error = "Invalid gts-ref-validation mode" }, statusCode: 422);
+
var node = await JsonNode.ParseAsync(req.Body).ConfigureAwait(false);
if (node is not JsonArray schemas)
return Results.Json(new { ok = false, error = "Request body must be a JSON array of GTS Type Schemas" }, statusCode: 422);
- var results = new List