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