Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions gts/src/gts/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand Down
5 changes: 3 additions & 2 deletions gts/src/gts/_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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]:
Expand Down
26 changes: 26 additions & 0 deletions gts/src/gts/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from gts._cli import main
from gts._cli import build_parser, main


@pytest.mark.parametrize(
Expand Down Expand Up @@ -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"

Expand Down
47 changes: 47 additions & 0 deletions tests/test_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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#",
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading