From 7296dd7e861a5c89ac33f378b598556849587cde Mon Sep 17 00:00:00 2001 From: Artifizer Date: Sun, 13 Sep 2026 22:15:51 +0300 Subject: [PATCH 1/3] feat: add configurable entity update mode Protect registry state by rejecting changed schema and instance registrations by default while preserving idempotent re-submissions. Allow callers to opt into replacement behavior with --allow-entity-updates, and return HTTP 409 for rejected changes. Signed-off-by: Artifizer --- gts/src/gts/_cli.py | 2 ++ gts/src/gts/_server.py | 5 +++-- gts/src/gts/ops.py | 26 +++++++++++++++++++++++ tests/test_cli.py | 12 ++++++++++- tests/test_ops.py | 47 ++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 30 +++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/gts/src/gts/_cli.py b/gts/src/gts/_cli.py index db7dd34..c14950f 100644 --- a/gts/src/gts/_cli.py +++ b/gts/src/gts/_cli.py @@ -107,6 +107,7 @@ def build_parser() -> argparse.ArgumentParser: s = sub.add_parser("server", help="Start the GTS HTTP server") s.add_argument("--host", default="127.0.0.1") s.add_argument("--port", type=int, default=8000) + s.add_argument("--allow-entity-updates", action="store_true") s = sub.add_parser("openapi-spec", help="Generate OpenAPI specification") s.add_argument( @@ -139,6 +140,7 @@ def main(argv: list[str] | None = None) -> None: config=args.config, verbose=args.verbose, exclude=exclude, + allow_entity_updates=getattr(args, "allow_entity_updates", False), ) if args.op == "server": diff --git a/gts/src/gts/_server.py b/gts/src/gts/_server.py index 89f4bf1..e7559fc 100644 --- a/gts/src/gts/_server.py +++ b/gts/src/gts/_server.py @@ -347,7 +347,7 @@ async def add_entity( validate: bool = Query(False), ) -> JSONResponse: result = self.ops.add_entity(body, validate=validate) - status_code = 200 if result.ok else 422 + status_code = 200 if result.ok else 409 if result.conflict else 422 return JSONResponse(result.to_dict(), status_code=status_code) async def add_entities( @@ -357,8 +357,9 @@ async def add_entities( return JSONResponse(self.ops.add_entities(body).to_dict()) async def add_schema(self, body: SchemaRegister) -> JSONResponse: + result = self.ops.add_schema(body.type_id, body.type_schema) return JSONResponse( - self.ops.add_schema(body.type_id, body.type_schema).to_dict() + result.to_dict(), status_code=409 if result.conflict else 200 ) async def validate_id(self, id: str = Query(..., alias="gts_id")) -> dict[str, Any]: diff --git a/gts/src/gts/ops.py b/gts/src/gts/ops.py index 2883a58..893f4cd 100644 --- a/gts/src/gts/ops.py +++ b/gts/src/gts/ops.py @@ -251,6 +251,7 @@ class GtsAddEntityResult: type_id: str | None = None is_type_schema: bool = False error: str = "" + conflict: bool = False def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = {"ok": self.ok} @@ -285,6 +286,7 @@ class GtsAddSchemaResult: ok: bool id: str = "" error: str = "" + conflict: bool = False def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = {"ok": self.ok} @@ -323,8 +325,10 @@ def __init__( config: str | None = None, verbose: int = 0, exclude: builtins.list[str] | None = None, + allow_entity_updates: bool = False, ) -> None: self.verbose = verbose + self.allow_entity_updates = allow_entity_updates self.cfg = self._load_config(config) self.path: str | list[str] | None = path self.exclude = exclude @@ -408,6 +412,17 @@ def add_entity( store_key = entity.gts_id.id if entity.is_schema else entity.raw_id previous = self.store.get(store_key) + if ( + previous + and not self.allow_entity_updates + and previous.content != entity.content + ): + return GtsAddEntityResult( + ok=False, + error=f"Entity '{store_key}' is already registered with different content", + is_type_schema=entity.is_schema, + conflict=True, + ) self.store.register(entity) try: @@ -447,6 +462,17 @@ def add_entities( def add_schema(self, type_id: str, schema: dict[str, Any]) -> GtsAddSchemaResult: try: + previous = self.store.get(type_id) + if ( + previous + and not self.allow_entity_updates + and previous.content != schema + ): + return GtsAddSchemaResult( + ok=False, + error=f"Entity '{type_id}' is already registered with different content", + conflict=True, + ) self.store.register_schema(type_id, schema) return GtsAddSchemaResult(ok=True, id=type_id) except Exception as e: # noqa: BLE001 - converted to a result object at API boundary diff --git a/tests/test_cli.py b/tests/test_cli.py index 9ce6a62..a0749ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ import pytest -from gts._cli import main +from gts._cli import build_parser, main @pytest.mark.parametrize( @@ -61,6 +61,16 @@ def test_cli_operations_emit_json(arguments, capsys): assert json.loads(capsys.readouterr().out) +def test_server_entity_updates_are_disabled_by_default(): + parser = build_parser() + + assert parser.parse_args(["server"]).allow_entity_updates is False + assert ( + parser.parse_args(["server", "--allow-entity-updates"]).allow_entity_updates + is True + ) + + def test_cli_writes_openapi_spec(tmp_path, capsys): output_path = tmp_path / "openapi.json" diff --git a/tests/test_ops.py b/tests/test_ops.py index 0d30bbc..0db6446 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -47,6 +47,26 @@ def test_add_schema_success(self, ops): assert result.is_type_schema is True assert result.id == "gts.x.test._.foo.v1~" + def test_add_identical_schema_is_idempotent(self, ops): + assert ops.add_entity(SCHEMA).ok is True + assert ops.add_entity(dict(SCHEMA)).ok is True + + def test_add_changed_schema_is_conflict(self, ops): + assert ops.add_entity(SCHEMA).ok is True + result = ops.add_entity({**SCHEMA, "properties": {"name": {"type": "integer"}}}) + + assert result.ok is False + assert result.conflict is True + assert ops.store.get("gts.x.test._.foo.v1~").content == SCHEMA + + def test_allow_entity_updates_replaces_changed_schema(self): + ops = GtsOps(path=None, allow_entity_updates=True) + changed_schema = {**SCHEMA, "properties": {"name": {"type": "integer"}}} + + assert ops.add_entity(SCHEMA).ok is True + assert ops.add_entity(changed_schema).ok is True + assert ops.store.get("gts.x.test._.foo.v1~").content == changed_schema + def test_add_schema_missing_gts_id(self, ops): bad_schema = { "$schema": "http://json-schema.org/draft-07/schema#", @@ -78,6 +98,26 @@ def test_add_instance_success(self, ops): assert result.ok is True assert result.is_type_schema is False + def test_add_identical_instance_is_idempotent(self, ops): + assert ops.add_entity(INSTANCE).ok is True + assert ops.add_entity(dict(INSTANCE)).ok is True + + def test_add_changed_instance_is_conflict(self, ops): + assert ops.add_entity(INSTANCE).ok is True + result = ops.add_entity({**INSTANCE, "name": "changed"}) + + assert result.ok is False + assert result.conflict is True + assert ops.store.get(INSTANCE["$id"]).content == INSTANCE + + def test_allow_entity_updates_replaces_changed_instance(self): + ops = GtsOps(path=None, allow_entity_updates=True) + changed_instance = {**INSTANCE, "name": "changed"} + + assert ops.add_entity(INSTANCE).ok is True + assert ops.add_entity(changed_instance).ok is True + assert ops.store.get(INSTANCE["$id"]).content == changed_instance + def test_add_instance_validate_failure_restores_previous(self, ops): ops.add_entity(SCHEMA) bad_instance = { @@ -111,6 +151,13 @@ def test_add_schema_legacy_success(self, ops): assert result.ok is True assert result.id == "gts.x.test._.legacy.v1~" + def test_add_schema_legacy_changed_content_is_conflict(self, ops): + assert ops.add_schema("gts.x.test._.legacy.v1~", {"type": "object"}).ok is True + result = ops.add_schema("gts.x.test._.legacy.v1~", {"type": "string"}) + + assert result.ok is False + assert result.conflict is True + def test_add_schema_legacy_failure(self, ops): result = ops.add_schema("gts.x.test._.legacy.v1", {"type": "object"}) assert result.ok is False diff --git a/tests/test_server.py b/tests/test_server.py index 741a144..3d99c3f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -75,6 +75,23 @@ def test_add_entity_failure(self, server): resp = run(server.add_entity(body={"no": "id"}, validate=False)) assert resp.status_code == 422 + def test_add_changed_entity_conflict(self, server): + assert run(server.add_entity(body=SCHEMA, validate=False)).status_code == 200 + changed_schema = {**SCHEMA, "properties": {"name": {"type": "integer"}}} + resp = run(server.add_entity(body=changed_schema, validate=False)) + + assert resp.status_code == 409 + + def test_allow_entity_updates(self): + server = GtsHttpServer(ops=GtsOps(path=None, allow_entity_updates=True)) + changed_schema = {**SCHEMA, "properties": {"name": {"type": "integer"}}} + + assert run(server.add_entity(body=SCHEMA, validate=False)).status_code == 200 + assert ( + run(server.add_entity(body=changed_schema, validate=False)).status_code + == 200 + ) + def test_add_entities(self, server): resp = run(server.add_entities(body=[SCHEMA, INSTANCE])) assert resp.status_code == 200 @@ -86,6 +103,19 @@ def test_add_schema(self, server): resp = run(server.add_schema(body)) assert resp.status_code == 200 + def test_add_schema_changed_content_conflict(self, server): + from gts._server import SchemaRegister + + initial = SchemaRegister( + type_id="gts.x.test._.bar.v1~", type_schema={"type": "object"} + ) + changed = SchemaRegister( + type_id="gts.x.test._.bar.v1~", type_schema={"type": "string"} + ) + + assert run(server.add_schema(initial)).status_code == 200 + assert run(server.add_schema(changed)).status_code == 409 + def test_validate_id(self, server): result = run(server.validate_id(id="gts.x.test._.foo.v1~")) assert result["valid"] is True From def08e9553f63c42b208dff9594ad2abe10ca49b Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 14:25:05 +0300 Subject: [PATCH 2/3] chore: bump .gts-spec to v0.13.4 Signed-off-by: Artifizer --- .gts-spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gts-spec b/.gts-spec index 5b5d578..deec643 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 5b5d5786bab1de1192d977a94752e492b66d17a6 +Subproject commit deec64342510e2456a7afd11f7cb42c426f8fda7 From dae8b594ccd2025034888bbf36871f316e757163 Mon Sep 17 00:00:00 2001 From: Artifizer Date: Tue, 15 Sep 2026 14:47:26 +0300 Subject: [PATCH 3/3] docs: update README.md to refer to gts-spec v0.13.4 as currently supported Signed-off-by: Artifizer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 45272f0..3af6258 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A minimal, idiomatic Python library for working with **GTS** ([Global Type System](https://github.com/gts-spec/gts-spec)) identifiers and JSON/JSON Schema artifacts. -Current supported GTS spec version: `0.13.1` +Current supported GTS spec version: `0.13.4` ## Roadmap