From c7ae4fc21de91341494566d11841f697e3462541 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Mon, 20 Apr 2026 12:14:10 +0300 Subject: [PATCH] feat: support x-gts-final/abstract modifiers and anonymous instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - x-gts-final blocks derived schemas; x-gts-abstract blocks direct instances - enforce mutual exclusion and reject schema-only keywords in instance content - anonymous instances (spec §3.7): non-GTS id paired with a GTS type field - server accepts ?validate=true alongside ?validation=true - Makefile: bootstrap .gts-spec venv, raise FD ulimit for e2e runs - bump .gts-spec submodule and golang.org/x/text to 0.36 Signed-off-by: Aviator 5 --- .gitignore | 3 + .gts-spec | 2 +- CLAUDE.md | 64 ++++ Makefile | 44 ++- README.md | 17 + go.mod | 2 +- go.sum | 4 +- gts/extract.go | 54 ++- gts/schema_compat.go | 14 + gts/schema_modifiers.go | 77 ++++ gts/schema_modifiers_test.go | 693 +++++++++++++++++++++++++++++++++++ gts/schema_traits.go | 37 ++ gts/store.go | 75 +++- gts/validate.go | 70 ++-- server/handlers.go | 59 ++- 15 files changed, 1134 insertions(+), 81 deletions(-) create mode 100644 CLAUDE.md create mode 100644 gts/schema_modifiers.go create mode 100644 gts/schema_modifiers_test.go diff --git a/.gitignore b/.gitignore index a59fa43..644bddf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ .vscode/ vendor/ coverage.out +bin/ +logs/ +e2e.log diff --git a/.gts-spec b/.gts-spec index 4ed5421..23ed727 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 4ed5421d2723a54bc4c2981e2f6a35e42d34c38f +Subproject commit 23ed727f1e85e808c2a4337d767d8309a5f40d0d diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ec90465 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. + +## Project Overview + +`gts-go` is the Go reference implementation of [GTS](https://github.com/GlobalTypeSystem/gts-spec) — library (`gts/`), CLI (`cmd/gts`), and an HTTP server (`cmd/gts-server` / `gts server`) that answers the REST API exercised by the shared gts-spec conformance suite. + +`.gts-spec/` is the spec vendored as a git submodule; `tests/` inside it is the conformance suite. See `README.md` for API/CLI details and `make help` for all targets. + +## Running the gts-spec Test Suite + +The short form is `make e2e PORT=8001` (PORT defaults to 8000; override if busy — the target fails fast if the port is already in use). It bootstraps the venv, rebuilds, starts the server, runs pytest, and shuts everything down. + +### Bootstrap the venv (first time only) + +Tests depend on `httprunner`. Installed into `.gts-spec/.venv/` (gitignored by the submodule). + +```bash +make e2e-venv # uses python3 by default +make e2e-venv PYTHON=python3.11 # Python 3.11 is the safest (httprunner still pins pydantic<2) +``` + +### Run pytest manually against a running server + +Useful when iterating on a single test without the full `make e2e` cycle. + +```bash +make build +./bin/gts server --port 8001 & + +PYTEST=".gts-spec/.venv/bin/python -m pytest" + +# Whole suite +$PYTEST .gts-spec/tests --gts-base-url http://127.0.0.1:8001 + +# One file / one class +$PYTEST .gts-spec/tests/test_refimpl_x_gts_final_abstract.py --gts-base-url http://127.0.0.1:8001 +$PYTEST .gts-spec/tests/test_op12_schema_vs_schema_validation.py::TestCaseOp12_FinalBase_RejectDerived --gts-base-url http://127.0.0.1:8001 +``` + +`GTS_BASE_URL` env var works too. The server holds state in memory with no reset endpoint — restart between full-suite runs. + +### Open-files limit on macOS + +Before running the full suite, raise the FD limit in the calling shell: + +```bash +ulimit -n 4096 +``` + +`httprunner` leaks a keep-alive socket per test class (the underlying +`requests.Session` is never closed), so FDs grow linearly. macOS's +default 256 soft cap is hit around test ~240 with `EMFILE: Too many +open files`. Linux defaults (1024+) are usually fine. `make e2e` runs +its commands in a subshell, so the `ulimit` must be set in the parent +shell that invokes `make`. Don't bake `ulimit` into the Makefile — +it's an environment concern, not a build step. + +## Working in This Repo + +- `.gts-spec` is a submodule. Bump with `make update-spec`, then rerun `make e2e` to pick up new spec tests. +- Handlers in `server/` stay thin — logic goes in `gts/` where it is unit-testable. New REST behavior usually already has coverage in `.gts-spec/tests/`; run the relevant file before and after to confirm. +- `make check` is the full local gate: fmt + vet + lint + test + e2e. diff --git a/Makefile b/Makefile index 1daf203..ead2abe 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,14 @@ CI := 1 -.PHONY: help build dev-fmt all check fmt vet lint test security coverage update-spec e2e +.PHONY: help build dev-fmt all check fmt vet lint test security coverage update-spec e2e-venv e2e + +# Python interpreter used to bootstrap the venv; override with `make e2e PYTHON=python3.11` +PYTHON ?= python3 +VENV_DIR := .gts-spec/.venv +VENV_PY := $(VENV_DIR)/bin/python + +# Port for the reference server during `make e2e`; override with `make e2e PORT=8001` +PORT ?= 8000 # Default target - show help .DEFAULT_GOAL := help @@ -62,17 +70,29 @@ coverage: update-spec: git submodule update --remote .gts-spec -# Run end-to-end tests against gts-spec -e2e: build - @echo "Starting server in background..." - @./bin/gts server --port 8000 & echo $$! > .server.pid - @sleep 2 - @echo "Running e2e tests..." - @PYTHONDONTWRITEBYTECODE=1 pytest -p no:cacheprovider --log-file=e2e.log ./.gts-spec/tests || (kill `cat .server.pid` 2>/dev/null; rm -f .server.pid; exit 1) - @echo "Stopping server..." - @kill `cat .server.pid` 2>/dev/null || true - @rm -f .server.pid - @echo "E2E tests completed successfully" +# Create/refresh the Python venv used by the gts-spec e2e test suite +e2e-venv: $(VENV_PY) + +$(VENV_PY): + @if [ ! -x "$(VENV_PY)" ]; then \ + echo "Creating venv at $(VENV_DIR) using $(PYTHON)..."; \ + $(PYTHON) -m venv $(VENV_DIR); \ + fi + @echo "Installing gts-spec e2e test dependencies..." + @$(VENV_PY) -m pip install --quiet --upgrade pip setuptools wheel + @$(VENV_PY) -m pip install --quiet httprunner + +# Run end-to-end tests against gts-spec. +e2e: build e2e-venv + @rm -rf logs e2e.log + @set -e; \ + trap 'kill `cat .server.pid 2>/dev/null` 2>/dev/null || true; rm -f .server.pid' INT TERM EXIT; \ + echo "Starting server in background on port $(PORT)..."; \ + ./bin/gts server --port $(PORT) & echo $$! > .server.pid; \ + sleep 2; \ + echo "Running e2e tests..."; \ + PYTHONDONTWRITEBYTECODE=1 $(VENV_PY) -m pytest -p no:cacheprovider --log-file=e2e.log --gts-base-url http://127.0.0.1:$(PORT) ./.gts-spec/tests; \ + echo "E2E tests completed successfully" # Run all quality checks check: fmt vet lint test e2e diff --git a/README.md b/README.md index b8f2887..602e543 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,23 @@ export GTS_BASE_URL=http://127.0.0.1:8001 pytest ``` +#### Raising the open-files limit (macOS) + +The gts-spec suite uses `httprunner`, which leaks a keep-alive socket per +test class (the underlying `requests.Session` is never closed). File +descriptors grow linearly and hit macOS's default 256 soft cap around +test ~240 with `EMFILE: Too many open files`. Before running the full +suite, raise the limit in your shell: + +```bash +ulimit -n 4096 +pytest +``` + +Linux defaults (typically 1024+) are usually high enough, but bumping +the limit there is harmless. `make e2e` runs in a subshell, so set +`ulimit` in the shell that invokes it. + ## License Apache License 2.0 diff --git a/go.mod b/go.mod index e557ea2..033148b 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,4 @@ require github.com/google/uuid v1.6.0 require github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 -require golang.org/x/text v0.14.0 // indirect +require golang.org/x/text v0.36.0 diff --git a/go.sum b/go.sum index a061278..773b659 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,5 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= diff --git a/gts/extract.go b/gts/extract.go index 026afe1..e3224ff 100644 --- a/gts/extract.go +++ b/gts/extract.go @@ -97,6 +97,36 @@ func NewJsonEntityWithFile(content map[string]any, cfg *GtsConfig, file *JsonFil return entity } +// EffectiveID returns the identifier used to key this entity in a registry +// and echo back to clients. Resolution order: +// 1. Parsed GTS ID (schemas and well-known instances). +// 2. Raw id field value for non-schemas (anonymous instances, spec §3.7) — +// used even when the schema reference cannot be resolved; schema presence +// is enforced at validation time, not at registration time. +// 3. File path (+ list sequence for multi-entity files) when the entity +// originated from a file. Mirrors gts-rust. +// +// Returns "" if none of the above apply. +func (e *JsonEntity) EffectiveID() string { + if e.GtsID != nil && e.GtsID.ID != "" { + return e.GtsID.ID + } + if !e.IsSchema && e.SelectedEntityField != "" { + if val, ok := e.Content[e.SelectedEntityField].(string); ok { + if id := strings.TrimSpace(val); id != "" { + return id + } + } + } + if e.File != nil { + if e.ListSequence != nil { + return fmt.Sprintf("%s#%d", e.File.Path, *e.ListSequence) + } + return e.File.Path + } + return "" +} + // setLabel sets the entity's label based on file, sequence, or GTS ID func (e *JsonEntity) setLabel() { if e.File != nil && e.ListSequence != nil { @@ -256,20 +286,16 @@ func ExtractID(content map[string]any, cfg *GtsConfig) *ExtractIDResult { result.SelectedSchemaIDField = &entity.SelectedSchemaIDField } - // Return effective_id() based on entity type - if entity.IsSchema || (entity.GtsID != nil) { - // For schemas and well-known instances: return GTS ID - if entity.GtsID != nil { - result.ID = entity.GtsID.ID - } - } else { - // For anonymous instances: return instance_id (UUID or non-GTS value from id field) - if entity.SelectedEntityField != "" { - if val, ok := content[entity.SelectedEntityField]; ok { - if strVal, ok := val.(string); ok { - result.ID = strVal - } - } + // Diagnostic: for schemas and well-known instances, return the parsed GTS ID. + // For non-schema entities without a valid GTS ID (anonymous or malformed), + // fall back to the raw value of the selected id field. This differs from + // EffectiveID (registrable), which requires a resolvable schema. + switch { + case entity.GtsID != nil: + result.ID = entity.GtsID.ID + case !entity.IsSchema && entity.SelectedEntityField != "": + if val, ok := content[entity.SelectedEntityField].(string); ok { + result.ID = val } } diff --git a/gts/schema_compat.go b/gts/schema_compat.go index 813ce81..b2ebc17 100644 --- a/gts/schema_compat.go +++ b/gts/schema_compat.go @@ -53,6 +53,20 @@ func (s *GtsStore) ValidateSchemaChain(schemaID string) *ValidateSchemaChainResu baseID := buildIDFromSegments(segments[:i+1]) derivedID := buildIDFromSegments(segments[:i+2]) + // Check x-gts-final: if the base type is final, derivation is not allowed. + baseEntity := s.Get(baseID) + if baseEntity != nil { + if isFinal, ok := baseEntity.Content[KeyXGtsFinal]; ok { + if final, isBool := isFinal.(bool); isBool && final { + return &ValidateSchemaChainResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("base type '%s' is final and cannot be extended", baseID), + } + } + } + } + baseContent, err := s.resolveSchemaRefsChecked(baseID) if err != nil { return &ValidateSchemaChainResult{ diff --git a/gts/schema_modifiers.go b/gts/schema_modifiers.go new file mode 100644 index 0000000..f28c7af --- /dev/null +++ b/gts/schema_modifiers.go @@ -0,0 +1,77 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +import "fmt" + +const ( + KeyXGtsFinal = "x-gts-final" + KeyXGtsAbstract = "x-gts-abstract" +) + +// ValidateSchemaModifiers checks that x-gts-final and x-gts-abstract are well-formed: +// boolean type, not both true, and not placed inside allOf entries at any depth. +func ValidateSchemaModifiers(content map[string]any) error { + final, err := readBoolModifier(content, KeyXGtsFinal) + if err != nil { + return err + } + abstract, err := readBoolModifier(content, KeyXGtsAbstract) + if err != nil { + return err + } + if final && abstract { + return fmt.Errorf("schema cannot declare both %s and %s as true", KeyXGtsFinal, KeyXGtsAbstract) + } + return validateModifierPlacement(content) +} + +func readBoolModifier(content map[string]any, key string) (bool, error) { + val, ok := content[key] + if !ok { + return false, nil + } + b, isBool := val.(bool) + if !isBool { + return false, fmt.Errorf("%s must be a boolean, got %T", key, val) + } + return b, nil +} + +func validateModifierPlacement(content map[string]any) error { + allOf, ok := content["allOf"].([]any) + if !ok { + return nil + } + for _, item := range allOf { + entry, ok := item.(map[string]any) + if !ok { + continue + } + if _, has := entry[KeyXGtsFinal]; has { + return fmt.Errorf("%s must be at the schema top level, not inside allOf", KeyXGtsFinal) + } + if _, has := entry[KeyXGtsAbstract]; has { + return fmt.Errorf("%s must be at the schema top level, not inside allOf", KeyXGtsAbstract) + } + if err := validateModifierPlacement(entry); err != nil { + return err + } + } + return nil +} + +// ValidateInstanceModifiers checks that schema-only keywords (x-gts-final, x-gts-abstract) +// do not appear in instance content. +func ValidateInstanceModifiers(content map[string]any) error { + if _, ok := content[KeyXGtsFinal]; ok { + return fmt.Errorf("%s is a schema-only keyword and must not appear in instances", KeyXGtsFinal) + } + if _, ok := content[KeyXGtsAbstract]; ok { + return fmt.Errorf("%s is a schema-only keyword and must not appear in instances", KeyXGtsAbstract) + } + return nil +} diff --git a/gts/schema_modifiers_test.go b/gts/schema_modifiers_test.go new file mode 100644 index 0000000..ba3e94f --- /dev/null +++ b/gts/schema_modifiers_test.go @@ -0,0 +1,693 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +import ( + "strings" + "testing" +) + +// ============================================================================= +// Helper functions +// ============================================================================= + +func mustRegisterMod(t *testing.T, store *GtsStore, content map[string]any) { + t.Helper() + if _, ok := content["$schema"]; !ok { + content["$schema"] = "http://json-schema.org/draft-07/schema#" + } + if err := store.Register(NewJsonEntity(content, DefaultGtsConfig())); err != nil { + t.Fatalf("register failed: %v", err) + } +} + +func mustRegisterInstance(t *testing.T, store *GtsStore, content map[string]any) { + t.Helper() + if err := store.Register(NewJsonEntity(content, DefaultGtsConfig())); err != nil { + t.Fatalf("register instance failed: %v", err) + } +} + +// ============================================================================= +// ValidateSchemaModifiers unit tests +// ============================================================================= + +func TestValidateSchemaModifiers_Default(t *testing.T) { + if err := ValidateSchemaModifiers(map[string]any{"type": "object"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateSchemaModifiers_FinalTrue(t *testing.T) { + if err := ValidateSchemaModifiers(map[string]any{"x-gts-final": true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateSchemaModifiers_AbstractTrue(t *testing.T) { + if err := ValidateSchemaModifiers(map[string]any{"x-gts-abstract": true}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateSchemaModifiers_BothTrue_Error(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{ + "x-gts-final": true, + "x-gts-abstract": true, + }) + if err == nil { + t.Error("expected error for both true") + } +} + +func TestValidateSchemaModifiers_NonBooleanFinal(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{"x-gts-final": "yes"}) + if err == nil { + t.Error("expected error for non-boolean x-gts-final") + } +} + +func TestValidateSchemaModifiers_NonBooleanAbstract(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{"x-gts-abstract": 1.0}) + if err == nil { + t.Error("expected error for non-boolean x-gts-abstract") + } +} + +func TestValidateSchemaModifiers_FalseIsNoop(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{ + "x-gts-final": false, + "x-gts-abstract": false, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateSchemaModifiers_TopLevelWithAllOfOk(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{ + "x-gts-final": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.a.base.v1~"}, + }, + }) + if err != nil { + t.Fatalf("top-level modifier should not be rejected: %v", err) + } +} + +func TestValidateSchemaModifiers_DirectAllOfRejected(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{ + "allOf": []any{ + map[string]any{"x-gts-final": true}, + }, + }) + if err == nil { + t.Error("expected error for x-gts-final inside allOf") + } +} + +func TestValidateSchemaModifiers_NestedAllOfFinalRejected(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{ + "allOf": []any{ + map[string]any{ + "allOf": []any{ + map[string]any{"x-gts-final": true}, + }, + }, + }, + }) + if err == nil { + t.Error("expected error for x-gts-final inside nested allOf") + } +} + +func TestValidateSchemaModifiers_NestedAllOfAbstractRejected(t *testing.T) { + err := ValidateSchemaModifiers(map[string]any{ + "allOf": []any{ + map[string]any{"type": "object"}, + map[string]any{ + "allOf": []any{ + map[string]any{ + "allOf": []any{ + map[string]any{"x-gts-abstract": true}, + }, + }, + }, + }, + }, + }) + if err == nil { + t.Error("expected error for x-gts-abstract deep inside nested allOf") + } +} + +func TestValidateInstanceModifiers_Clean(t *testing.T) { + err := ValidateInstanceModifiers(map[string]any{"id": "test", "name": "foo"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateInstanceModifiers_HasFinal(t *testing.T) { + err := ValidateInstanceModifiers(map[string]any{"id": "test", "x-gts-final": true}) + if err == nil { + t.Error("expected error for x-gts-final in instance") + } +} + +func TestValidateInstanceModifiers_HasAbstract(t *testing.T) { + err := ValidateInstanceModifiers(map[string]any{"id": "test", "x-gts-abstract": true}) + if err == nil { + t.Error("expected error for x-gts-abstract in instance") + } +} + +// ============================================================================= +// x-gts-final integration tests +// ============================================================================= + +func TestFinal_RejectDerivedSchema(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.final.base.v1~", + "type": "object", + "x-gts-final": true, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.final.base.v1~x.testmod._.derived.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.final.base.v1~"}, + map[string]any{"type": "object", "properties": map[string]any{"extra": map[string]any{"type": "string"}}}, + }, + }) + result := store.ValidateSchemaChain("gts.x.testmod.final.base.v1~x.testmod._.derived.v1~") + if result.OK { + t.Error("expected validation to fail for derived from final base") + } + if !strings.Contains(result.Error, "final") { + t.Errorf("error should mention 'final', got: %s", result.Error) + } +} + +func TestFinal_AllowWellKnownInstance(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.final.inst.v1~", + "type": "object", + "x-gts-final": true, + "required": []any{"id", "description"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + }, + }) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.final.inst.v1~x.testmod._.running.v1", + "description": "Running state", + }) + result := store.ValidateInstance("gts.x.testmod.final.inst.v1~x.testmod._.running.v1") + if !result.OK { + t.Errorf("expected instance of final type to pass, got: %s", result.Error) + } +} + +func TestFinal_MidChainFinal(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalmid.base.v1~", + "type": "object", + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalmid.base.v1~x.testmod._.mid.v1~", + "type": "object", + "x-gts-final": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finalmid.base.v1~"}, + map[string]any{"type": "object"}, + }, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalmid.base.v1~x.testmod._.mid.v1~x.testmod._.leaf.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finalmid.base.v1~x.testmod._.mid.v1~"}, + map[string]any{"type": "object"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.testmod.finalmid.base.v1~x.testmod._.mid.v1~x.testmod._.leaf.v1~") + if result.OK { + t.Error("expected validation to fail - mid is final") + } +} + +func TestFinal_SiblingUnaffected(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalsib.base.v1~", + "type": "object", + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalsib.base.v1~x.testmod._.final_b.v1~", + "type": "object", + "x-gts-final": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finalsib.base.v1~"}, + map[string]any{"type": "object"}, + }, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalsib.base.v1~x.testmod._.sibling_c.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finalsib.base.v1~"}, + map[string]any{"type": "object", "properties": map[string]any{"extra": map[string]any{"type": "string"}}}, + }, + }) + result := store.ValidateSchemaChain("gts.x.testmod.finalsib.base.v1~x.testmod._.sibling_c.v1~") + if !result.OK { + t.Errorf("sibling should pass - base is not final, got: %s", result.Error) + } +} + +func TestFinal_FalseIsNoop(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalfalse.base.v1~", + "type": "object", + "x-gts-final": false, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalfalse.base.v1~x.testmod._.derived.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finalfalse.base.v1~"}, + map[string]any{"type": "object"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.testmod.finalfalse.base.v1~x.testmod._.derived.v1~") + if !result.OK { + t.Errorf("final=false should allow derivation, got: %s", result.Error) + } +} + +// ============================================================================= +// x-gts-abstract integration tests +// ============================================================================= + +func TestAbstract_RejectDirectInstance(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.reject.v1~", + "type": "object", + "x-gts-abstract": true, + "required": []any{"id", "name"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + }, + }) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.abs.reject.v1~x.testmod._.item.v1", + "name": "Direct item", + }) + result := store.ValidateInstance("gts.x.testmod.abs.reject.v1~x.testmod._.item.v1") + if result.OK { + t.Error("expected instance of abstract type to fail validation") + } + if !strings.Contains(result.Error, "abstract") { + t.Errorf("error should mention 'abstract', got: %s", result.Error) + } +} + +func TestAbstract_AllowDerivedSchema(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.derive.v1~", + "type": "object", + "x-gts-abstract": true, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.derive.v1~x.testmod._.concrete.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.abs.derive.v1~"}, + map[string]any{"type": "object", "properties": map[string]any{"extra": map[string]any{"type": "string"}}}, + }, + }) + result := store.ValidateSchemaChain("gts.x.testmod.abs.derive.v1~x.testmod._.concrete.v1~") + if !result.OK { + t.Errorf("derived from abstract should pass, got: %s", result.Error) + } +} + +func TestAbstract_AllowInstanceOfConcreteDerived(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.concinst.v1~", + "type": "object", + "x-gts-abstract": true, + "required": []any{"id", "name"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + }, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.concinst.v1~x.testmod._.concrete.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.abs.concinst.v1~"}, + map[string]any{"type": "object"}, + }, + }) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.abs.concinst.v1~x.testmod._.concrete.v1~x.testmod._.item.v1", + "name": "My Item", + }) + result := store.ValidateInstance("gts.x.testmod.abs.concinst.v1~x.testmod._.concrete.v1~x.testmod._.item.v1") + if !result.OK { + t.Errorf("instance of concrete derived should pass, got: %s", result.Error) + } +} + +func TestAbstract_ChainOfAbstracts(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.chain.v1~", + "type": "object", + "x-gts-abstract": true, + "required": []any{"id"}, + "properties": map[string]any{"id": map[string]any{"type": "string"}}, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~", + "type": "object", + "x-gts-abstract": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.abs.chain.v1~"}, + map[string]any{"type": "object"}, + }, + }) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~x.testmod._.leaf.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~"}, + map[string]any{"type": "object"}, + }, + }) + // Instance of concrete leaf — should pass + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~x.testmod._.leaf.v1~x.testmod._.item.v1", + }) + result := store.ValidateInstance("gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~x.testmod._.leaf.v1~x.testmod._.item.v1") + if !result.OK { + t.Errorf("instance of concrete leaf should pass, got: %s", result.Error) + } + // Instance of abstract mid — should fail + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~x.testmod._.direct.v1", + }) + result2 := store.ValidateInstance("gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~x.testmod._.direct.v1") + if result2.OK { + t.Error("instance of abstract mid should fail") + } +} + +func TestAbstract_FalseIsNoop(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.absfalse.base.v1~", + "type": "object", + "x-gts-abstract": false, + "required": []any{"id"}, + "properties": map[string]any{"id": map[string]any{"type": "string"}}, + }) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.absfalse.base.v1~x.testmod._.item.v1", + }) + result := store.ValidateInstance("gts.x.testmod.absfalse.base.v1~x.testmod._.item.v1") + if !result.OK { + t.Errorf("abstract=false should allow instances, got: %s", result.Error) + } +} + +// ============================================================================= +// Interaction tests +// ============================================================================= + +func TestBothModifiers_Rejected(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.both.invalid.v1~", + "type": "object", + "x-gts-final": true, + "x-gts-abstract": true, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + result := store.ValidateEntity("gts.x.testmod.both.invalid.v1~") + if result.OK { + t.Error("expected validation to fail for both final+abstract") + } +} + +func TestAbstractBaseFinalDerived(t *testing.T) { + store := NewGtsStore(nil) + // Abstract base + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.absfinal.base.v1~", + "type": "object", + "x-gts-abstract": true, + "required": []any{"id", "name"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + }, + }) + // Concrete + final derived + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~", + "type": "object", + "x-gts-final": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.absfinal.base.v1~"}, + map[string]any{"type": "object", "properties": map[string]any{"extra": map[string]any{"type": "string"}}}, + }, + }) + // B is valid schema + chainResult := store.ValidateSchemaChain("gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~") + if !chainResult.OK { + t.Errorf("concrete derived from abstract should pass, got: %s", chainResult.Error) + } + // Instance of B — should pass + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~x.testmod._.item.v1", + "name": "My Item", + "extra": "value", + }) + instResult := store.ValidateInstance("gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~x.testmod._.item.v1") + if !instResult.OK { + t.Errorf("instance of concrete final should pass, got: %s", instResult.Error) + } + // Derived from B — should fail (B is final) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~x.testmod._.sub.v1~", + "type": "object", + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~"}, + map[string]any{"type": "object"}, + }, + }) + subResult := store.ValidateSchemaChain("gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~x.testmod._.sub.v1~") + if subResult.OK { + t.Error("derived from final should fail") + } + // Direct instance of A — should fail (A is abstract) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.absfinal.base.v1~x.testmod._.direct.v1", + "name": "Direct from abstract", + }) + directResult := store.ValidateInstance("gts.x.testmod.absfinal.base.v1~x.testmod._.direct.v1") + if directResult.OK { + t.Error("direct instance of abstract should fail") + } +} + +func TestSchemaKeywordsInInstance_FinalRejected(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.kwininst.base.v1~", + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.kwininst.base.v1~x.testmod._.item.v1", + "x-gts-final": true, + }) + result := store.ValidateEntity("gts.x.testmod.kwininst.base.v1~x.testmod._.item.v1") + if result.OK { + t.Error("instance with x-gts-final should fail entity validation") + } + if !strings.Contains(result.Error, "x-gts-final") { + t.Errorf("error should mention x-gts-final, got: %s", result.Error) + } +} + +func TestSchemaKeywordsInInstance_AbstractRejected(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.kwininst2.base.v1~", + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + mustRegisterInstance(t, store, map[string]any{ + "id": "gts.x.testmod.kwininst2.base.v1~x.testmod._.item.v1", + "x-gts-abstract": true, + }) + result := store.ValidateEntity("gts.x.testmod.kwininst2.base.v1~x.testmod._.item.v1") + if result.OK { + t.Error("instance with x-gts-abstract should fail entity validation") + } + if !strings.Contains(result.Error, "x-gts-abstract") { + t.Errorf("error should mention x-gts-abstract, got: %s", result.Error) + } +} + +func TestFinal_WithTraitsFullyResolved(t *testing.T) { + store := NewGtsStore(nil) + // Base with trait schema (required priority, no default) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finaltrait.base.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "retention": map[string]any{"type": "string", "default": "P30D"}, + "priority": map[string]any{"type": "integer"}, + }, + }, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + // Final derived that provides all traits + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finaltrait.base.v1~x.testmod._.leaf.v1~", + "type": "object", + "x-gts-final": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finaltrait.base.v1~"}, + map[string]any{ + "type": "object", + "x-gts-traits": map[string]any{ + "priority": 5, + }, + }, + }, + }) + result := store.ValidateSchemaTraits("gts.x.testmod.finaltrait.base.v1~x.testmod._.leaf.v1~") + if !result.OK { + t.Errorf("final with resolved traits should pass, got: %s", result.Error) + } +} + +func TestFinal_WithTraitsMissing(t *testing.T) { + store := NewGtsStore(nil) + // Base with required trait (no default) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalmiss.base.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "priority": map[string]any{"type": "integer"}, + }, + "required": []any{"priority"}, + }, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + // Final derived that does NOT provide the required trait + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalmiss.base.v1~x.testmod._.leaf.v1~", + "type": "object", + "x-gts-final": true, + "allOf": []any{ + map[string]any{"$ref": "gts.x.testmod.finalmiss.base.v1~"}, + map[string]any{"type": "object"}, + }, + }) + result := store.ValidateSchemaTraits("gts.x.testmod.finalmiss.base.v1~x.testmod._.leaf.v1~") + if result.OK { + t.Error("final with missing required traits should fail") + } + if !strings.Contains(result.Error, "priority") { + t.Errorf("error should mention missing trait 'priority', got: %s", result.Error) + } +} + +func TestAbstract_WithIncompleteTraitsOk(t *testing.T) { + store := NewGtsStore(nil) + // Abstract base with required trait (no default) — should pass because abstract + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.abstrait.base.v1~", + "type": "object", + "x-gts-abstract": true, + "x-gts-traits-schema": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "priority": map[string]any{"type": "integer"}, + }, + "required": []any{"priority"}, + }, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + result := store.ValidateSchemaTraits("gts.x.testmod.abstrait.base.v1~") + if !result.OK { + t.Errorf("abstract with incomplete traits should pass, got: %s", result.Error) + } +} + +func TestFinal_NonBooleanRejected(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.finalbadval.base.v1~", + "type": "object", + "x-gts-final": "yes", + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + result := store.ValidateEntity("gts.x.testmod.finalbadval.base.v1~") + if result.OK { + t.Error("non-boolean x-gts-final should fail entity validation") + } +} + +func TestAbstract_NonBooleanRejected(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterMod(t, store, map[string]any{ + "$id": "gts.x.testmod.absbadval.base.v1~", + "type": "object", + "x-gts-abstract": 1.0, + "properties": map[string]any{"name": map[string]any{"type": "string"}}, + }) + result := store.ValidateEntity("gts.x.testmod.absbadval.base.v1~") + if result.OK { + t.Error("non-boolean x-gts-abstract should fail entity validation") + } +} diff --git a/gts/schema_traits.go b/gts/schema_traits.go index b55e9c1..7d47584 100644 --- a/gts/schema_traits.go +++ b/gts/schema_traits.go @@ -493,6 +493,23 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe // Apply defaults effectiveTraits := applyDefaults(effectiveTraitSchema, mergedTraits, 0) + // Check if the leaf schema (rightmost segment) is abstract. + // Abstract schemas are not leaf schemas, so trait validation is skipped entirely — + // they are not required to provide trait values or satisfy required constraints. + leafEntity := s.Get(schemaID) + isAbstractLeaf := false + if leafEntity != nil { + if val, ok := leafEntity.Content[KeyXGtsAbstract]; ok { + if ab, isBool := val.(bool); isBool && ab { + isAbstractLeaf = true + } + } + } + + if isAbstractLeaf { + return &ValidateSchemaTraitsResult{SchemaID: schemaID, OK: true} + } + // Validate errs := validateTraitsAgainstSchema(effectiveTraitSchema, effectiveTraits, true) if len(errs) > 0 { @@ -632,6 +649,16 @@ func (s *GtsStore) ValidateEntity(entityID string) *ValidateEntityResult { } if entity.IsSchema { + // Validate schema modifiers (x-gts-final, x-gts-abstract): type, mutual exclusion, placement. + if err := ValidateSchemaModifiers(entity.Content); err != nil { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "schema", + OK: false, + Error: err.Error(), + } + } + // For schemas: run OP#12 chain validation + OP#13 traits validation chainResult := s.ValidateSchemaChain(entityID) if !chainResult.OK { @@ -667,6 +694,16 @@ func (s *GtsStore) ValidateEntity(entityID string) *ValidateEntityResult { return &ValidateEntityResult{EntityID: entityID, EntityType: "schema", OK: true} } + // Check that schema-only keywords do not appear in instance content. + if err := ValidateInstanceModifiers(entity.Content); err != nil { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "instance", + OK: false, + Error: err.Error(), + } + } + // For instances: validate against schema instanceResult := s.ValidateInstance(entityID) if !instanceResult.OK { diff --git a/gts/store.go b/gts/store.go index 64c8c53..7eb468f 100644 --- a/gts/store.go +++ b/gts/store.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "strings" + "sync" ) // StoreGtsObjectNotFoundError is returned when a GTS entity is not found in the store @@ -60,8 +61,15 @@ func DefaultRegistryConfig() *RegistryConfig { } } -// GtsStore manages a collection of JSON entities and schemas with optional GTS reference validation +// GtsStore manages a collection of JSON entities and schemas with optional GTS reference validation. +// +// mu serializes writers (Register, RegisterSchema, Unregister, RegisterWithValidation) +// so that snapshot/register/validate/rollback flows cannot interleave with other +// writers for the same id. Readers (Get, Items, List, Count, validators) intentionally +// do not acquire mu — validators are invoked via RegisterWithValidation while mu is +// already held by the same goroutine, so taking it again would deadlock. type GtsStore struct { + mu sync.Mutex byID map[string]*JsonEntity reader GtsReader config *RegistryConfig @@ -104,27 +112,62 @@ func (s *GtsStore) populateFromReader() { if entity == nil { break } - if entity.GtsID != nil && entity.GtsID.ID != "" { - s.byID[entity.GtsID.ID] = entity + if key := entity.EffectiveID(); key != "" { + s.byID[key] = entity } } } -// Register adds a JsonEntity to the store with optional GTS reference validation +// Register adds a JsonEntity to the store with optional GTS reference validation. func (s *GtsStore) Register(entity *JsonEntity) error { - if entity.GtsID == nil || entity.GtsID.ID == "" { - return fmt.Errorf("entity must have a valid gts_id") + s.mu.Lock() + defer s.mu.Unlock() + return s.registerLocked(entity) +} + +// registerLocked is the lock-free body of Register. Callers must hold s.mu. +func (s *GtsStore) registerLocked(entity *JsonEntity) error { + key := entity.EffectiveID() + if key == "" { + return fmt.Errorf("entity must have a gts_id or a non-empty id field") } - // Perform validation if enabled if s.config.ValidateGtsReferences { if err := s.validateEntityGtsReferences(entity); err != nil { - return fmt.Errorf("GTS reference validation failed for entity %s: %w", entity.GtsID.ID, err) + return fmt.Errorf("GTS reference validation failed for entity %s: %w", key, err) } } - s.byID[entity.GtsID.ID] = entity - log.Printf("Registered entity: %s (schema: %v, refs: %d)", entity.GtsID.ID, entity.IsSchema, len(entity.GtsRefs)) + s.byID[key] = entity + log.Printf("Registered entity: %s (schema: %v, refs: %d)", key, entity.IsSchema, len(entity.GtsRefs)) + return nil +} + +// RegisterWithValidation atomically registers entity, runs validate, and rolls back on +// failure. The entire snapshot/register/validate/rollback critical section runs under +// s.mu, so concurrent writers for the same id cannot interleave between the snapshot +// and the rollback. validate is invoked while s.mu is held; it must not call back into +// the store's writer methods (Register, Unregister, RegisterSchema, RegisterWithValidation) +// or it will deadlock. Validators that only read the store are safe. +func (s *GtsStore) RegisterWithValidation(entity *JsonEntity, validate func(id string) error) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := entity.EffectiveID() + previous, hadPrevious := s.byID[key] + + if err := s.registerLocked(entity); err != nil { + return err + } + + if err := validate(key); err != nil { + if hadPrevious { + s.byID[key] = previous + } else { + delete(s.byID, key) + } + return err + } return nil } @@ -147,6 +190,8 @@ func (s *GtsStore) RegisterSchema(typeID string, schema map[string]any) error { IsSchema: true, } + s.mu.Lock() + defer s.mu.Unlock() s.byID[typeID] = entity return nil } @@ -188,6 +233,14 @@ func (s *GtsStore) Items() map[string]*JsonEntity { return s.byID } +// Unregister removes an entity from the store by its effective ID. +// Used to roll back registrations that fail post-register validation. +func (s *GtsStore) Unregister(entityID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.byID, entityID) +} + // Count returns the number of entities in the store func (s *GtsStore) Count() int { return len(s.byID) @@ -241,7 +294,7 @@ func (s *GtsStore) validateEntityGtsReferences(entity *JsonEntity) error { var errors []string for _, ref := range entity.GtsRefs { - if ref.ID == entity.GtsID.ID { + if entity.GtsID != nil && ref.ID == entity.GtsID.ID { // Skip self-references continue } diff --git a/gts/validate.go b/gts/validate.go index cd427ca..89c3d9e 100644 --- a/gts/validate.go +++ b/gts/validate.go @@ -46,35 +46,51 @@ type ValidationResult struct { Error string `json:"error"` } -// ValidateInstance validates an object instance against its schema -// Returns ValidationResult with ok=true if validation succeeds -func (s *GtsStore) ValidateInstance(gtsID string) *ValidationResult { - // Parse and validate GTS ID - gid, err := NewGtsID(gtsID) - if err != nil { - return &ValidationResult{ - ID: gtsID, - OK: false, - Error: fmt.Sprintf("Invalid GTS ID: %v", err), +// ValidateInstance validates an object instance against its schema. +// Accepts either a well-known GTS instance ID or an anonymous instance id +// (e.g. a UUID paired with a separate "type" field on the stored entity, +// spec §3.7). Returns ValidationResult with ok=true if validation succeeds. +func (s *GtsStore) ValidateInstance(instanceID string) *ValidationResult { + // Well-known GTS id first; fall back to a raw store lookup by the + // passed string so anonymous instances (keyed by UUID) resolve too. + lookupID := instanceID + if IsValidGtsID(instanceID) { + gid, err := NewGtsID(instanceID) + if err != nil { + return &ValidationResult{ + ID: instanceID, + OK: false, + Error: fmt.Sprintf("Invalid GTS ID: %v", err), + } } + lookupID = gid.ID } // Get the instance from store - obj := s.Get(gid.ID) + obj := s.Get(lookupID) if obj == nil { return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: false, - Error: (&StoreGtsObjectNotFoundError{EntityID: gtsID}).Error(), + Error: (&StoreGtsObjectNotFoundError{EntityID: instanceID}).Error(), + } + } + + // Schema-only keywords must not appear in instance content. + if err := ValidateInstanceModifiers(obj.Content); err != nil { + return &ValidationResult{ + ID: instanceID, + OK: false, + Error: err.Error(), } } // Check if instance has a schema ID if obj.SchemaID == "" { return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: false, - Error: (&StoreGtsSchemaForInstanceNotFoundError{EntityID: gid.ID}).Error(), + Error: (&StoreGtsSchemaForInstanceNotFoundError{EntityID: lookupID}).Error(), } } @@ -82,7 +98,7 @@ func (s *GtsStore) ValidateInstance(gtsID string) *ValidationResult { schemaEntity := s.Get(obj.SchemaID) if schemaEntity == nil { return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: false, Error: (&StoreGtsSchemaNotFoundError{EntityID: obj.SchemaID}).Error(), } @@ -90,17 +106,27 @@ func (s *GtsStore) ValidateInstance(gtsID string) *ValidationResult { if !schemaEntity.IsSchema { return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: false, Error: fmt.Sprintf("entity '%s' is not a schema", obj.SchemaID), } } + // Check x-gts-abstract: abstract types cannot have direct instances. + if isAbstract, ok := schemaEntity.Content[KeyXGtsAbstract]; ok { + if abstract, isBool := isAbstract.(bool); isBool && abstract { + return &ValidationResult{ + ID: instanceID, + OK: false, + Error: fmt.Sprintf("type '%s' is abstract and cannot have direct instances", obj.SchemaID), + } + } + } + // Validate the instance against the schema - err = s.validateWithSchema(obj.Content, schemaEntity.Content) - if err != nil { + if err := s.validateWithSchema(obj.Content, schemaEntity.Content); err != nil { return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: false, Error: err.Error(), } @@ -116,14 +142,14 @@ func (s *GtsStore) ValidateInstance(gtsID string) *ValidationResult { errorMsgs = append(errorMsgs, e.Error()) } return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: false, Error: fmt.Sprintf("x-gts-ref validation failed: %s", strings.Join(errorMsgs, "; ")), } } return &ValidationResult{ - ID: gtsID, + ID: instanceID, OK: true, Error: "", } diff --git a/server/handlers.go b/server/handlers.go index 9f41c7d..0bb9f0a 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -6,6 +6,7 @@ Released under Apache License 2.0 package server import ( + "errors" "fmt" "net/http" "strings" @@ -130,7 +131,12 @@ func (s *Server) handleAddEntity(w http.ResponseWriter, r *http.Request) { } entity := gts.NewJsonEntity(content, gts.DefaultGtsConfig()) - if entity.GtsID == nil { + + // Determine the effective id used both to key the entity in the store + // and to echo back to the client. Well-known instances and schemas use + // their GTS id; anonymous instances (spec §3.7) use their raw id field. + responseID := entity.EffectiveID() + if responseID == "" { status := http.StatusOK if validationParam == "true" { status = http.StatusUnprocessableEntity @@ -203,32 +209,48 @@ func (s *Server) handleAddEntity(w http.ResponseWriter, r *http.Request) { } } - // Check if instance validation is requested via query parameter - validation := r.URL.Query().Get("validation") - if validation == "true" && !entity.IsSchema { - // For non-schema entities with validation=true, register first then validate - err := s.store.Register(entity) - if err != nil { - s.writeJSON(w, http.StatusOK, map[string]any{ + // Validate schema modifiers (x-gts-final, x-gts-abstract) for schemas. + if entity.IsSchema { + if err := gts.ValidateSchemaModifiers(entity.Content); err != nil { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ "ok": false, "error": err.Error(), }) return } + } - // Validate the instance - result := s.store.ValidateInstance(entity.GtsID.ID) - if !result.OK { - s.writeJSON(w, http.StatusOK, map[string]any{ + // Check if validation is requested via query parameter + validation := r.URL.Query().Get("validate") + if validation == "" { + validation = r.URL.Query().Get("validation") + } + if validation == "true" { + err := s.store.RegisterWithValidation(entity, func(id string) error { + if entity.IsSchema { + if r := s.store.ValidateSchemaChain(id); !r.OK { + return errors.New(r.Error) + } + if r := s.store.ValidateSchemaTraits(id); !r.OK { + return errors.New(r.Error) + } + return nil + } + if r := s.store.ValidateInstance(id); !r.OK { + return errors.New(r.Error) + } + return nil + }) + if err != nil { + s.writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ "ok": false, - "error": result.Error, + "error": err.Error(), }) return } - s.writeJSON(w, http.StatusOK, map[string]any{ "ok": true, - "gts_id": entity.GtsID.ID, + "gts_id": responseID, }) return } @@ -244,7 +266,7 @@ func (s *Server) handleAddEntity(w http.ResponseWriter, r *http.Request) { s.writeJSON(w, http.StatusOK, map[string]any{ "ok": true, - "gts_id": entity.GtsID.ID, + "gts_id": responseID, }) } @@ -260,7 +282,8 @@ func (s *Server) handleAddEntities(w http.ResponseWriter, r *http.Request) { for i, content := range contents { entity := gts.NewJsonEntity(content, gts.DefaultGtsConfig()) - if entity.GtsID == nil { + responseID := entity.EffectiveID() + if responseID == "" { result[i] = map[string]any{ "ok": false, "error": "Unable to extract GTS ID from entity", @@ -279,7 +302,7 @@ func (s *Server) handleAddEntities(w http.ResponseWriter, r *http.Request) { result[i] = map[string]any{ "ok": true, - "gts_id": entity.GtsID.ID, + "gts_id": responseID, } successCount++ }