From 776633e884651fee3ba050075354dfbc50e2cd39 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Thu, 2 Apr 2026 15:49:38 +0300 Subject: [PATCH 1/2] feat: add x-gts-final and x-gts-abstract schema modifier support - Define section 9.11 with full semantics for final (no inheritance) and abstract (no direct instances) modifiers - Update OP#6 and OP#12 descriptions to reference modifier enforcement - Add x-gts-abstract to event and capability base schemas, x-gts-final to vm_state schema - Add OP#12 tests for final type derivation rejection, mid-chain final, and sibling unaffected - Add OP#6 tests for abstract type instance rejection (well-known, anonymous, derived concrete) - Add cross-cutting tests for mutual exclusion and combined scenarios Signed-off-by: Aviator 5 --- .gitignore | 4 +- README.md | 107 +- .../gts.x.core.events.type.v1~.schema.json | 3 +- ....x.core.modules.capability.v1~.schema.json | 3 +- ...s.x.infra.compute.vm_state.v1~.schema.json | 3 +- tests/helpers/__init__.py | 0 tests/helpers/http_run_helpers.py | 103 ++ .../test_op12_schema_vs_schema_validation.py | 232 ++++- tests/test_op13_schema_traits_validation.py | 74 +- tests/test_op6_schema_validation.py | 304 ++++++ tests/test_refimpl_x_gts_final_abstract.py | 944 ++++++++++++++++++ 11 files changed, 1651 insertions(+), 126 deletions(-) create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/http_run_helpers.py create mode 100644 tests/test_refimpl_x_gts_final_abstract.py diff --git a/.gitignore b/.gitignore index 53dca9c..f544743 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea/ .DS_Store __pycache__/ -logs/ \ No newline at end of file +logs/ +.venv/ +CLAUDE.local.md \ No newline at end of file diff --git a/README.md b/README.md index 469b9df..f55506a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,17 @@ See the [Practical Benefits for Service and Platform Vendors](#51-practical-bene - [8.1 Single-segment regex (type or instance)](#81-single-segment-regex-type-or-instance) - [8.2 Chained identifier regex](#82-chained-identifier-regex) - [9. Reference Implementation Recommendations](#9-reference-implementation-recommendations) + - [9.1 Identifier reference in JSON and JSON Schema](#91---identifier-reference-in-json-and-json-schema) + - [9.2 GTS operations (OP#1 - OP#13)](#92---gts-operations-op1---op13) + - [9.3 GTS entities registration](#93---gts-entities-registration) + - [9.4 CLI support](#94---cli-support) + - [9.5 Web server with OpenAPI](#95---web-server-with-openapi) + - [9.6 `x-gts-ref` support](#96---x-gts-ref-support) - [9.7 Schema Traits (`x-gts-traits-schema` / `x-gts-traits`)](#97---schema-traits-x-gts-traits-schema--x-gts-traits) + - [9.8 YAML support](#98---yaml-support) + - [9.9 TypeSpec support](#99---typespec-support) + - [9.10 UUID as object IDs](#910---uuid-as-object-ids) + - [9.11 Schema Modifiers (`x-gts-final` / `x-gts-abstract`)](#911---schema-modifiers-x-gts-final--x-gts-abstract) - [10. Collecting Identifiers with Wildcards](#10-collecting-identifiers-with-wildcards) - [11. JSON and JSON Schema Conventions](#11-json-and-json-schema-conventions) - [12. Notes and Best Practices](#12-notes-and-best-practices) @@ -328,6 +338,8 @@ GTS chained identifiers express type derivation through **left-to-right inherita This inheritance model enables safe extensibility: third-party vendors can extend platform base types while maintaining full compatibility with the core system. +**Schema modifiers**: Schemas may optionally declare `"x-gts-final": true` to prohibit further derivation, or `"x-gts-abstract": true` to require that instances use a concrete derived type rather than the base type directly. See section 9.11 for full semantics. + **Implementation pattern: Hybrid storage for extensible schemas** GTS types inheritance enables a powerful database design pattern that combines **structured storage** for base type fields with **flexible JSON storage** for derived type extensions. This approach provides: @@ -530,6 +542,8 @@ Example: - `id: "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456"` - In this case, the explicit `type` field MAY be omitted, since the schema/type can be derived from the `id` prefix up to the final `~`. +**Note:** A type marked with `"x-gts-abstract": true` cannot have direct instances (well-known or anonymous). Instances must reference a concrete (non-abstract) derived type as the rightmost type in the chain. See section 9.11. + This split is common in event systems: **topics/streams** are often well-known instances, while individual **events** are anonymous. See `./examples/events` and the field-level recommendations in section **9.1**. Example: @@ -1256,13 +1270,13 @@ Implement and expose all operations OP#1–OP#13 listed above and add appropriat - **OP#3 - ID Parsing**: Decompose identifiers into constituent parts (vendor, package, namespace, type, version, etc.) - **OP#4 - ID Pattern Matching**: Match identifiers against patterns containing wildcards - **OP#5 - ID to UUID Mapping**: Generate deterministic UUIDs from GTS identifiers -- **OP#6 - Schema Validation**: Validate object instances against their corresponding schemas +- **OP#6 - Schema Validation**: Validate object instances against their corresponding schemas. When validating instances, if the rightmost type in the chain is marked `x-gts-abstract: true`, validation MUST fail (see section 9.11) - **OP#7 - Relationship Resolution**: Load schemas and instances, resolve inter-dependencies, and detect broken references - **OP#8 - Compatibility Checking**: Verify that schemas with different MINOR versions are compatible - **OP#9 - Version Casting**: Transform instances between compatible MINOR versions - **OP#10 - Query Execution**: Filter identifier collections using the GTS query language - **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) -- **OP#12 - Schema vs Schema Validation**: Validate derived schemas against their base schemas. Derived schemas using `allOf` must conform to all constraints defined in their parent schemas throughout the inheritance hierarchy. This ensures type safety in schema extension and prevents constraint violations in multi-level schema hierarchies. +- **OP#12 - Schema vs Schema Validation**: Validate derived schemas against their base schemas. Derived schemas using `allOf` must conform to all constraints defined in their parent schemas throughout the inheritance hierarchy. This ensures type safety in schema extension and prevents constraint violations in multi-level schema hierarchies. When validating derived schemas, if any base schema in the chain is marked `x-gts-final: true`, validation MUST fail (see section 9.11) - **OP#13 - Schema Traits Validation**: Validate schema traits (`x-gts-traits-schema` / `x-gts-traits`). See section 9.7 for full semantics and validation rules. ### 9.3 - GTS entities registration @@ -1500,6 +1514,95 @@ Ensure generated schemas use GTS identifiers as `$id` for types and keep any `x- Support UUIDs (format: `uuid`) for instance `id` fields. +### 9.11 - Schema Modifiers (`x-gts-final` / `x-gts-abstract`) + +A **schema modifier** is a boolean annotation on a GTS schema that restricts how the type participates in the GTS type system. Modifiers can be used to control inheritance and instantiation behavior. There are two keywords for this purpose: `x-gts-final` and `x-gts-abstract`. + +#### 9.11.1 Keywords + +| Keyword | JSON type | Purpose | Typical location | +|---------|-----------|---------|------------------| +| **`x-gts-final`** | `boolean` | Marks the type as **not inheritable** — no derived schemas may reference it as a base | Leaf schemas; enum-like types with a fixed set of well-known instances | +| **`x-gts-abstract`** | `boolean` | Marks the type as **not directly instantiable** — instances must conform to a concrete derived type | Base/ancestor schemas that serve purely as templates | + +**Schema-only keywords:** Both `x-gts-final` and `x-gts-abstract` are **schema annotation keywords** and MUST only appear in JSON Schema documents (documents with `$schema`). They MUST NOT appear in instance documents. Implementations MUST reject instances that contain these keywords. + +**Allowed values:** The only meaningful value is `true`. If the keyword is absent or set to `false`, it has no effect (the schema behaves normally — both inheritable and instantiable). Implementations MUST reject non-boolean values. + +**Mutual exclusion:** A schema MUST NOT declare both `"x-gts-final": true` and `"x-gts-abstract": true`. This combination is semantically meaningless (a type that can be neither inherited from nor instantiated serves no purpose) and MUST be rejected during schema registration or validation. + +| Modifier combination | Inheritance allowed? | Direct instances allowed? | +|---|---|---| +| *(default / neither)* | Yes | Yes | +| `x-gts-abstract: true` | Yes | No | +| `x-gts-final: true` | No | Yes | +| Both `true` | **INVALID** — MUST be rejected | — | + +#### 9.11.2 `x-gts-final` semantics + +When a schema declares `"x-gts-final": true`: + +1. **Registration guard**: When a new schema is registered whose `allOf` / `$ref` chain references a final type as a base, the registry MUST reject the registration (when validation is enabled). Specifically, if the derived schema's `$id` is of the form `gts://gts.A~B~` and schema `A~` has `"x-gts-final": true`, then registering `A~B~` MUST fail. + +2. **Validation via `/validate-schema` (OP#12)**: When validating a derived schema against its base chain, if any base schema in the chain is marked `x-gts-final`, validation MUST fail with an error indicating that the base type is final and cannot be extended. + +3. **Instances are unaffected**: A final type MAY have well-known instances and anonymous instances. `x-gts-final` restricts only schema derivation, not instantiation. + +4. **No propagation**: `x-gts-final` applies only to the schema that declares it. It does NOT propagate to base types in the chain. For a chain `A~ → B~ → C~`, if `B~` is final, then `C~` is invalid. But `A~` can still be inherited by types other than `B~`'s descendants. + +5. **Keyword placement**: The keyword MUST appear at the **top level** of the JSON Schema document, adjacent to `$id` and `$schema`: + +```json +{ + "$id": "gts://gts.x.infra.compute.vm_state.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-final": true, + "type": "object", + "properties": { "..." : {} } +} +``` + +For derived schemas using `allOf`, the keyword MUST appear at the top level, NOT inside the `allOf` entries: + +```json +{ + "$id": "gts://gts.x.core.events.type.v1~x.vendor._.order_event.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-final": true, + "type": "object", + "allOf": [ + { "$ref": "gts://gts.x.core.events.type.v1~" }, + { "..." : {} } + ] +} +``` + +#### 9.11.3 `x-gts-abstract` semantics + +When a schema declares `"x-gts-abstract": true`: + +1. **Instance registration guard**: When a new instance is registered whose **rightmost type** in the chain is an abstract type, the registry MUST reject the registration (when validation is enabled). For example, if `gts.x.core.events.type.v1~` is abstract, registering a well-known instance `gts.x.core.events.type.v1~x.vendor._.some_event.v1` MUST fail (the rightmost type is abstract). However, if a concrete derived schema `gts.x.core.events.type.v1~x.vendor._.order_event.v1~` exists and is not abstract, then registering instance `gts.x.core.events.type.v1~x.vendor._.order_event.v1~x.vendor._.order_placed.v1` succeeds. + +2. **Validation via `/validate-instance` and `/validate-entity` (OP#6)**: When validating an instance (through either the instance-specific or unified entity endpoint), the system resolves the rightmost type in the chain. If that type is abstract, validation MUST fail. + +3. **Schema derivation is unaffected**: An abstract type is explicitly intended to be inherited from. Registering derived schemas from an abstract type always succeeds (subject to other validation rules). + +4. **No propagation**: A derived type is concrete by default. If `A~` is abstract and `B~` derives from `A~`, `B~` is concrete unless it also declares `"x-gts-abstract": true`. + +5. **Anonymous instances**: For combined anonymous instance IDs like `gts.A~`, the system resolves the type from the prefix. If that type is abstract, the instance MUST be rejected. + +#### 9.11.4 Interaction with `x-gts-traits` + +- **Abstract types with traits**: An abstract base type MAY declare `x-gts-traits-schema`. Since abstract types cannot have direct instances, trait values (`x-gts-traits`) do not need to be fully resolved on the abstract type itself. Trait resolution completeness is only enforced on concrete (leaf) schemas (this is already the existing behavior from section 9.7.5). + +- **Final types with traits**: A final type MAY declare `x-gts-traits` values. Since no derived types can exist, all trait values MUST be fully resolved on the final type itself. If the effective trait schema has required properties without defaults and the final type does not provide them via `x-gts-traits`, validation MUST fail. + +#### 9.11.5 Registration enforcement + +Enforcement follows the same pattern as existing `?validate=true` behavior: checks are performed when validation is enabled on registration, and always enforced on explicit validation endpoints (`/validate-schema`, `/validate-instance`, `/validate-entity`). This is consistent with existing patterns (e.g., `x-gts-ref` checks in section 9.6). + +See `./examples/typespec/vms/schemas/states/gts.x.infra.compute.vm_state.v1~.schema.json` for an example of a final type, `./examples/modules/schemas/gts.x.core.modules.capability.v1~.schema.json` for another final type, and `./examples/events/schemas/gts.x.core.events.type.v1~.schema.json` for an example of an abstract base type. + ## 10. Collecting Identifiers with Wildcards diff --git a/examples/events/schemas/gts.x.core.events.type.v1~.schema.json b/examples/events/schemas/gts.x.core.events.type.v1~.schema.json index bd4826a..80afda5 100644 --- a/examples/events/schemas/gts.x.core.events.type.v1~.schema.json +++ b/examples/events/schemas/gts.x.core.events.type.v1~.schema.json @@ -1,9 +1,10 @@ { "$id": "gts://gts.x.core.events.type.v1~", "$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-abstract": true, "title": "Event Envelope (Common Fields)", "type": "object", - "description": "Base event type definition", + "description": "Abstract base event type definition. Instances must use a concrete derived type.", "required": [ "id", "type", diff --git a/examples/modules/schemas/gts.x.core.modules.capability.v1~.schema.json b/examples/modules/schemas/gts.x.core.modules.capability.v1~.schema.json index 9d91229..d38f778 100644 --- a/examples/modules/schemas/gts.x.core.modules.capability.v1~.schema.json +++ b/examples/modules/schemas/gts.x.core.modules.capability.v1~.schema.json @@ -1,7 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "gts://gts.x.core.modules.capability.v1~", - "description": "Base schema for any application modules capabilities", + "x-gts-final": true, + "description": "Schema for application module capabilities. Final: no derived types allowed.", "required": [ "description", "id" diff --git a/examples/typespec/vms/schemas/states/gts.x.infra.compute.vm_state.v1~.schema.json b/examples/typespec/vms/schemas/states/gts.x.infra.compute.vm_state.v1~.schema.json index f7478f4..8ea914b 100644 --- a/examples/typespec/vms/schemas/states/gts.x.infra.compute.vm_state.v1~.schema.json +++ b/examples/typespec/vms/schemas/states/gts.x.infra.compute.vm_state.v1~.schema.json @@ -1,8 +1,9 @@ { "$id": "gts://gts.x.infra.compute.vm_state.v1~", "$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-final": true, "title": "Base VM Power State Type", - "description": "Base type for all VM power states.", + "description": "Base type for all VM power states. Final: no derived types allowed.", "type": "object", "required": [ "gtsId", diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/http_run_helpers.py b/tests/helpers/http_run_helpers.py new file mode 100644 index 0000000..5626b6b --- /dev/null +++ b/tests/helpers/http_run_helpers.py @@ -0,0 +1,103 @@ +"""Shared test helpers for HttpRunner-based GTS tests. + +Provides reusable Step builders for registering and validating schemas, +instances, and entities via the GTS HTTP API. +""" + +from httprunner import Step, RunRequest + + +def register(gts_id, schema_body, label="register schema"): + """Register a schema via POST /entities.""" + body = { + "$$id": gts_id, + "$$schema": "http://json-schema.org/draft-07/schema#", + **schema_body, + } + return Step( + RunRequest(label) + .post("/entities") + .with_json(body) + .validate() + .assert_equal("status_code", 200) + ) + + +def register_derived(gts_id, base_ref, overlay, label="register derived", top_level=None): + """Register a derived schema that uses allOf with a $$ref. + + top_level: optional dict of extra keys to add at schema top level + (e.g. {"x-gts-final": True}) — these MUST NOT go inside allOf. + """ + body = { + "$$id": gts_id, + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$$ref": base_ref}, + overlay, + ], + } + if top_level: + body.update(top_level) + return Step( + RunRequest(label) + .post("/entities") + .with_json(body) + .validate() + .assert_equal("status_code", 200) + ) + + +def register_instance(instance_body, label="register instance"): + """Register an instance via POST /entities.""" + return Step( + RunRequest(label) + .post("/entities") + .with_json(instance_body) + .validate() + .assert_equal("status_code", 200) + ) + + +def validate_schema(schema_id, expect_ok, label="validate schema"): + """Validate a derived schema via POST /validate-schema.""" + step = ( + RunRequest(label) + .post("/validate-schema") + .with_json({"schema_id": schema_id}) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", expect_ok) + ) + return Step(step) + + +def validate_entity(entity_id, expect_ok, label="validate entity", expected_entity_type=None): + """Validate an entity via POST /validate-entity.""" + step = ( + RunRequest(label) + .post("/validate-entity") + .with_json({"entity_id": entity_id}) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", expect_ok) + ) + if expected_entity_type is not None: + step = step.assert_equal("body.entity_type", expected_entity_type) + return Step(step) + + +def validate_instance(instance_id, expect_ok, label="validate instance", expected_id=None): + """Validate an instance via POST /validate-instance.""" + step = ( + RunRequest(label) + .post("/validate-instance") + .with_json({"instance_id": instance_id}) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", expect_ok) + ) + if expected_id is not None: + step = step.assert_equal("body.id", expected_id) + return Step(step) diff --git a/tests/test_op12_schema_vs_schema_validation.py b/tests/test_op12_schema_vs_schema_validation.py index 565e746..77681d3 100644 --- a/tests/test_op12_schema_vs_schema_validation.py +++ b/tests/test_op12_schema_vs_schema_validation.py @@ -1,60 +1,12 @@ from .conftest import get_gts_base_url +from .helpers.http_run_helpers import ( + register as _register, + register_derived as _register_derived, + validate_schema as _validate_schema, +) from httprunner import HttpRunner, Config, Step, RunRequest -# --------------------------------------------------------------------------- -# Helper functions to reduce boilerplate across repetitive test patterns -# --------------------------------------------------------------------------- - -def _register(gts_id, schema_body, label="register schema"): - """Register a schema via POST /entities.""" - body = { - "$$id": gts_id, - "$$schema": "http://json-schema.org/draft-07/schema#", - **schema_body, - } - return Step( - RunRequest(label) - .post("/entities") - .with_json(body) - .validate() - .assert_equal("status_code", 200) - ) - - -def _register_derived(gts_id, base_ref, overlay, label="register derived"): - """Register a derived schema that uses allOf with a $$ref.""" - body = { - "$$id": gts_id, - "$$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "allOf": [ - {"$$ref": base_ref}, - overlay, - ], - } - return Step( - RunRequest(label) - .post("/entities") - .with_json(body) - .validate() - .assert_equal("status_code", 200) - ) - - -def _validate_schema(schema_id, expect_ok, label="validate schema"): - """Validate a derived schema via POST /validate-schema.""" - step = ( - RunRequest(label) - .post("/validate-schema") - .with_json({"schema_id": schema_id}) - .validate() - .assert_equal("status_code", 200) - .assert_equal("body.ok", expect_ok) - ) - return Step(step) - - def _make_2level_constraint_drop_steps( ns, prop_name, prop_type, constraint_kw, constraint_val, extra_base=None, @@ -3570,5 +3522,179 @@ def test_start(self): ] +# --------------------------------------------------------------------------- +# x-gts-final tests (OP#12 extension — final types cannot be inherited) +# --------------------------------------------------------------------------- + + +class TestCaseOp12_FinalBase_RejectDerived(HttpRunner): + """OP#12 / x-gts-final: Derived schema from a final base MUST fail validation. + + Base type declares x-gts-final: true. A derived schema referencing it + via allOf/$ref MUST be rejected by /validate-schema. + """ + + config = Config("OP#12 x-gts-final: reject derived from final base").base_url( + get_gts_base_url() + ) + teststeps = [ + _register( + "gts://gts.x.test12.final.base.v1~", + { + "type": "object", + "x-gts-final": True, + "properties": { + "name": {"type": "string"}, + }, + }, + "register final base schema", + ), + _register_derived( + "gts://gts.x.test12.final.base.v1~x.test12._.derived.v1~", + "gts://gts.x.test12.final.base.v1~", + { + "type": "object", + "properties": { + "extra": {"type": "string"}, + }, + }, + "register derived from final base", + ), + _validate_schema( + "gts.x.test12.final.base.v1~x.test12._.derived.v1~", + False, + "validate derived from final base should fail", + ), + ] + + +class TestCaseOp12_FinalMidChain(HttpRunner): + """OP#12 / x-gts-final: Mid-chain final type blocks further derivation. + + Chain: A~ -> B~(final) -> C~. Validating C~ MUST fail because B~ is final. + """ + + config = Config("OP#12 x-gts-final: mid-chain final blocks derivation").base_url( + get_gts_base_url() + ) + teststeps = [ + _register( + "gts://gts.x.test12.finalmid.base.v1~", + { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }, + "register base A", + ), + _register_derived( + "gts://gts.x.test12.finalmid.base.v1~x.test12._.mid.v1~", + "gts://gts.x.test12.finalmid.base.v1~", + { + "type": "object", + "properties": { + "midField": {"type": "string"}, + }, + }, + "register mid B (final)", + top_level={"x-gts-final": True}, + ), + _register_derived( + "gts://gts.x.test12.finalmid.base.v1~x.test12._.mid.v1~x.test12._.leaf.v1~", + "gts://gts.x.test12.finalmid.base.v1~x.test12._.mid.v1~", + { + "type": "object", + "properties": { + "leafField": {"type": "string"}, + }, + }, + "register leaf C from final B", + ), + _validate_schema( + "gts.x.test12.finalmid.base.v1~x.test12._.mid.v1~x.test12._.leaf.v1~", + False, + "validate C should fail - B is final", + ), + ] + + +class TestCaseOp12_FinalSiblingUnaffected(HttpRunner): + """OP#12 / x-gts-final: Sibling of a final type can still derive from shared base. + + A~ -> B~(final) and A~ -> C~. C~ is valid because A~ is not final. + """ + + config = Config("OP#12 x-gts-final: sibling of final is unaffected").base_url( + get_gts_base_url() + ) + teststeps = [ + _register( + "gts://gts.x.test12.finalsib.base.v1~", + { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }, + "register base A", + ), + _register_derived( + "gts://gts.x.test12.finalsib.base.v1~x.test12._.final_b.v1~", + "gts://gts.x.test12.finalsib.base.v1~", + { + "type": "object", + }, + "register B (final) from A", + top_level={"x-gts-final": True}, + ), + _register_derived( + "gts://gts.x.test12.finalsib.base.v1~x.test12._.sibling_c.v1~", + "gts://gts.x.test12.finalsib.base.v1~", + { + "type": "object", + "properties": { + "extra": {"type": "string"}, + }, + }, + "register C (sibling) from A", + ), + _validate_schema( + "gts.x.test12.finalsib.base.v1~x.test12._.sibling_c.v1~", + True, + "validate C should pass - A is not final", + ), + ] + + +class TestCaseOp12_FinalBase_SelfValidationPasses(HttpRunner): + """OP#12 / x-gts-final: A final base type itself MUST pass /validate-schema. + + The final modifier restricts derivation, not the type's own validity. + """ + + config = Config("OP#12 x-gts-final: final base self-validation passes").base_url( + get_gts_base_url() + ) + teststeps = [ + _register( + "gts://gts.x.test12.finalself.base.v1~", + { + "type": "object", + "x-gts-final": True, + "properties": { + "name": {"type": "string"}, + }, + }, + "register final base schema", + ), + _validate_schema( + "gts.x.test12.finalself.base.v1~", + True, + "validate final base itself should pass", + ), + ] + + if __name__ == "__main__": TestCaseTestOp12SchemaValidation_DerivedSchemaFullyMatches().test_start() diff --git a/tests/test_op13_schema_traits_validation.py b/tests/test_op13_schema_traits_validation.py index 534a3ed..9bc5d84 100644 --- a/tests/test_op13_schema_traits_validation.py +++ b/tests/test_op13_schema_traits_validation.py @@ -1,71 +1,11 @@ from .conftest import get_gts_base_url -from httprunner import HttpRunner, Config, Step, RunRequest - - -# --------------------------------------------------------------------------- -# Helper functions -# --------------------------------------------------------------------------- - -def _register(gts_id, schema_body, label="register schema"): - """Register a schema via POST /entities.""" - body = { - "$$id": gts_id, - "$$schema": "http://json-schema.org/draft-07/schema#", - **schema_body, - } - return Step( - RunRequest(label) - .post("/entities") - .with_json(body) - .validate() - .assert_equal("status_code", 200) - ) - - -def _register_derived(gts_id, base_ref, overlay, label="register derived"): - """Register a derived schema that uses allOf with a $$ref.""" - body = { - "$$id": gts_id, - "$$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "allOf": [ - {"$$ref": base_ref}, - overlay, - ], - } - return Step( - RunRequest(label) - .post("/entities") - .with_json(body) - .validate() - .assert_equal("status_code", 200) - ) - - -def _validate_schema(schema_id, expect_ok, label="validate schema"): - """Validate a derived schema via POST /validate-schema.""" - step = ( - RunRequest(label) - .post("/validate-schema") - .with_json({"schema_id": schema_id}) - .validate() - .assert_equal("status_code", 200) - .assert_equal("body.ok", expect_ok) - ) - return Step(step) - - -def _validate_entity(entity_id, expect_ok, label="validate entity"): - """Validate an entity via POST /validate-entity.""" - step = ( - RunRequest(label) - .post("/validate-entity") - .with_json({"entity_id": entity_id}) - .validate() - .assert_equal("status_code", 200) - .assert_equal("body.ok", expect_ok) - ) - return Step(step) +from .helpers.http_run_helpers import ( + register as _register, + register_derived as _register_derived, + validate_entity as _validate_entity, + validate_schema as _validate_schema, +) +from httprunner import HttpRunner, Config # --------------------------------------------------------------------------- diff --git a/tests/test_op6_schema_validation.py b/tests/test_op6_schema_validation.py index 89aa61a..a7465ce 100644 --- a/tests/test_op6_schema_validation.py +++ b/tests/test_op6_schema_validation.py @@ -858,5 +858,309 @@ def test_start(self): ] +# --------------------------------------------------------------------------- +# x-gts-abstract tests (OP#6 extension — abstract types cannot have direct instances) +# --------------------------------------------------------------------------- + + +class TestCaseOp6_AbstractType_RejectWellKnownInstance(HttpRunner): + """OP#6 / x-gts-abstract: Well-known instance of abstract type MUST fail validation. + + Base type declares x-gts-abstract: true. Registering and validating a + well-known instance whose rightmost type is the abstract type MUST fail. + """ + + config = Config("OP#6 x-gts-abstract: reject well-known instance of abstract type").base_url( + get_gts_base_url() + ) + teststeps = [ + # Register abstract base schema + Step( + RunRequest("register abstract base schema") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test6.abstract.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-abstract": True, + "type": "object", + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + # Register well-known instance of abstract type + Step( + RunRequest("register instance of abstract type") + .post("/entities") + .with_json({ + "id": "gts.x.test6.abstract.base.v1~x.test6._.my_item.v1", + "name": "My Item", + }) + .validate() + .assert_equal("status_code", 200) + ), + # Validate instance — should fail because base is abstract + Step( + RunRequest("validate instance of abstract type should fail") + .post("/validate-instance") + .with_json({ + "instance_id": "gts.x.test6.abstract.base.v1~x.test6._.my_item.v1", + }) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", False) + .assert_equal("body.id", "gts.x.test6.abstract.base.v1~x.test6._.my_item.v1") + ), + ] + + +class TestCaseOp6_AbstractType_RejectAnonInstance(HttpRunner): + """OP#6 / x-gts-abstract: Anonymous instance of abstract type MUST fail validation. + + Combined anonymous instance whose type prefix is abstract MUST be rejected. + """ + + config = Config("OP#6 x-gts-abstract: reject anonymous instance of abstract type").base_url( + get_gts_base_url() + ) + teststeps = [ + # Register abstract base schema + Step( + RunRequest("register abstract base schema") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test6.abstractanon.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-abstract": True, + "type": "object", + "required": ["id", "type", "name"], + "properties": { + "id": {"type": "string", "format": "uuid"}, + "type": {"type": "string"}, + "name": {"type": "string"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + # Register anonymous instance with type pointing to abstract schema + Step( + RunRequest("register anonymous instance of abstract type") + .post("/entities") + .with_json({ + "id": "a1b2c3d4-5678-4abc-8def-111111111111", + "type": "gts.x.test6.abstractanon.base.v1~", + "name": "Anon Item", + }) + .validate() + .assert_equal("status_code", 200) + ), + # Validate instance — should fail because type is abstract + Step( + RunRequest("validate anonymous instance of abstract type should fail") + .post("/validate-instance") + .with_json({ + "instance_id": "a1b2c3d4-5678-4abc-8def-111111111111", + }) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", False) + .assert_equal("body.id", "a1b2c3d4-5678-4abc-8def-111111111111") + ), + ] + + +class TestCaseOp6_AbstractType_AllowInstanceOfConcreteDerived(HttpRunner): + """OP#6 / x-gts-abstract: Instance of concrete derived type MUST pass. + + Abstract base A~, concrete derived A~B~. Instance of B~ should pass + because B~ is not abstract. + """ + + config = Config("OP#6 x-gts-abstract: allow instance of concrete derived type").base_url( + get_gts_base_url() + ) + teststeps = [ + # Register abstract base schema + Step( + RunRequest("register abstract base schema") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test6.abstractder.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-abstract": True, + "type": "object", + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + # Register concrete derived schema + Step( + RunRequest("register concrete derived schema") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test6.abstractder.base.v1~x.test6._.concrete.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.test6.abstractder.base.v1~"}, + { + "type": "object", + "properties": { + "extra": {"type": "string"}, + }, + }, + ], + }) + .validate() + .assert_equal("status_code", 200) + ), + # Register well-known instance of concrete derived type + Step( + RunRequest("register instance of concrete derived type") + .post("/entities") + .with_json({ + "id": "gts.x.test6.abstractder.base.v1~x.test6._.concrete.v1~x.test6._.my_item.v1", + "name": "My Item", + "extra": "some value", + }) + .validate() + .assert_equal("status_code", 200) + ), + # Validate instance — should pass because concrete.v1~ is not abstract + Step( + RunRequest("validate instance of concrete derived should pass") + .post("/validate-instance") + .with_json({ + "instance_id": "gts.x.test6.abstractder.base.v1~x.test6._.concrete.v1~x.test6._.my_item.v1", + }) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", True) + ), + ] + + +class TestCaseOp6_AbstractType_ValidateEntityRejectsInstance(HttpRunner): + """OP#6 / x-gts-abstract: /validate-entity MUST also reject instance of abstract type. + + The unified /validate-entity endpoint must enforce the abstract constraint + the same way /validate-instance does. + """ + + config = Config("OP#6 x-gts-abstract: validate-entity rejects instance of abstract type").base_url( + get_gts_base_url() + ) + teststeps = [ + # Register abstract base schema + Step( + RunRequest("register abstract base schema") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test6.abstractent.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-abstract": True, + "type": "object", + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + # Register well-known instance of abstract type + Step( + RunRequest("register instance of abstract type") + .post("/entities") + .with_json({ + "id": "gts.x.test6.abstractent.base.v1~x.test6._.my_item.v1", + "name": "My Item", + }) + .validate() + .assert_equal("status_code", 200) + ), + # Validate via /validate-entity — should fail because base is abstract + Step( + RunRequest("validate-entity instance of abstract type should fail") + .post("/validate-entity") + .with_json({ + "entity_id": "gts.x.test6.abstractent.base.v1~x.test6._.my_item.v1", + }) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", False) + .assert_equal("body.entity_type", "instance") + ), + ] + + +class TestCaseOp6_AbstractType_RejectCombinedAnonInstance(HttpRunner): + """OP#6 / x-gts-abstract: Combined anonymous instance (gts.type~UUID) of abstract type MUST fail. + + Tests the combined anonymous format where the type is resolved from the + ID prefix (section 9.11.3.5). + """ + + config = Config("OP#6 x-gts-abstract: reject combined anonymous instance of abstract type").base_url( + get_gts_base_url() + ) + teststeps = [ + # Register abstract base schema + Step( + RunRequest("register abstract base schema") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test6.abstractcomb.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-abstract": True, + "type": "object", + "required": ["id", "type", "name"], + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + "name": {"type": "string"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + # Register combined anonymous instance (type prefix is abstract) + Step( + RunRequest("register combined anonymous instance of abstract type") + .post("/entities") + .with_json({ + "id": "gts.x.test6.abstractcomb.base.v1~d2e3f4a5-6789-4abc-8def-222222222222", + "type": "gts.x.test6.abstractcomb.base.v1~", + "name": "Combined Anon Item", + }) + .validate() + .assert_equal("status_code", 200) + ), + # Validate combined anonymous instance — should fail because type is abstract + Step( + RunRequest("validate combined anonymous instance of abstract type should fail") + .post("/validate-instance") + .with_json({ + "instance_id": "gts.x.test6.abstractcomb.base.v1~d2e3f4a5-6789-4abc-8def-222222222222", + }) + .validate() + .assert_equal("status_code", 200) + .assert_equal("body.ok", False) + .assert_equal("body.id", "gts.x.test6.abstractcomb.base.v1~d2e3f4a5-6789-4abc-8def-222222222222") + ), + ] + + if __name__ == "__main__": TestCaseTestOp6ValidateInstance_ValidInstance().test_start() diff --git a/tests/test_refimpl_x_gts_final_abstract.py b/tests/test_refimpl_x_gts_final_abstract.py new file mode 100644 index 0000000..f66a77d --- /dev/null +++ b/tests/test_refimpl_x_gts_final_abstract.py @@ -0,0 +1,944 @@ +"""x-gts-final / x-gts-abstract — comprehensive and interaction tests. + +Tests for schema modifier keywords (section 9.11): +- x-gts-final: type cannot be inherited +- x-gts-abstract: type cannot be directly instantiated + +These tests cover edge cases, boolean validation, schema-only enforcement, +and interactions with x-gts-traits (OP#13). +""" + +from .conftest import get_gts_base_url +from .helpers.http_run_helpers import ( + register as _register, + register_derived as _register_derived, + register_instance as _register_instance, + validate_entity as _validate_entity, + validate_instance as _validate_instance, + validate_schema as _validate_schema, +) +from httprunner import HttpRunner, Config, Step, RunRequest + + +# --------------------------------------------------------------------------- +# x-gts-final tests +# --------------------------------------------------------------------------- + + +class TestCaseFinal_RejectDerivedSchema(HttpRunner): + """x-gts-final: Derived schema from a final base MUST fail validation.""" + + config = Config("final: reject derived schema").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.final.reject.v1~", + { + "type": "object", + "x-gts-final": True, + "properties": {"name": {"type": "string"}}, + }, + "register final base", + ), + _register_derived( + "gts://gts.x.testfa.final.reject.v1~x.testfa._.derived.v1~", + "gts://gts.x.testfa.final.reject.v1~", + {"type": "object", "properties": {"extra": {"type": "string"}}}, + "register derived from final", + ), + _validate_schema( + "gts.x.testfa.final.reject.v1~x.testfa._.derived.v1~", + False, + "validate derived should fail", + ), + ] + + +class TestCaseFinal_AllowWellKnownInstance(HttpRunner): + """x-gts-final: Well-known instances of a final type MUST pass validation.""" + + config = Config("final: allow well-known instance").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.final.inst.v1~", + { + "type": "object", + "x-gts-final": True, + "required": ["id", "description"], + "properties": { + "id": {"type": "string"}, + "description": {"type": "string"}, + }, + }, + "register final type", + ), + _register_instance( + { + "id": "gts.x.testfa.final.inst.v1~x.testfa._.running.v1", + "description": "Running state", + }, + "register well-known instance", + ), + _validate_instance( + "gts.x.testfa.final.inst.v1~x.testfa._.running.v1", + True, + "validate well-known instance should pass", + ), + ] + + +class TestCaseFinal_AllowAnonymousInstance(HttpRunner): + """x-gts-final: Anonymous instances of a final type MUST pass validation. + + Uses combined anonymous instance format: gts.type.v1~ + """ + + config = Config("final: allow anonymous instance").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.final.anon.v1~", + { + "type": "object", + "x-gts-final": True, + "required": ["id", "type", "name"], + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register final type", + ), + _register_instance( + { + "id": "gts.x.testfa.final.anon.v1~b1c2d3e4-5678-4abc-8def-aabbccddeeff", + "type": "gts.x.testfa.final.anon.v1~", + "name": "Anonymous item", + }, + "register combined anonymous instance", + ), + _validate_instance( + "gts.x.testfa.final.anon.v1~b1c2d3e4-5678-4abc-8def-aabbccddeeff", + True, + "validate combined anonymous instance should pass", + ), + ] + + +class TestCaseFinal_MidChainFinal(HttpRunner): + """x-gts-final: Mid-chain final blocks further derivation. + + Chain: A~ -> B~(final) -> C~. Validating C~ MUST fail. + """ + + config = Config("final: mid-chain final blocks derivation").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalmid.base.v1~", + {"type": "object", "properties": {"name": {"type": "string"}}}, + "register base A", + ), + _register_derived( + "gts://gts.x.testfa.finalmid.base.v1~x.testfa._.mid.v1~", + "gts://gts.x.testfa.finalmid.base.v1~", + {"type": "object"}, + "register mid B (final)", + top_level={"x-gts-final": True}, + ), + _register_derived( + "gts://gts.x.testfa.finalmid.base.v1~x.testfa._.mid.v1~x.testfa._.leaf.v1~", + "gts://gts.x.testfa.finalmid.base.v1~x.testfa._.mid.v1~", + {"type": "object", "properties": {"extra": {"type": "string"}}}, + "register leaf C from final B", + ), + _validate_schema( + "gts.x.testfa.finalmid.base.v1~x.testfa._.mid.v1~x.testfa._.leaf.v1~", + False, + "validate C should fail - B is final", + ), + ] + + +class TestCaseFinal_SiblingUnaffected(HttpRunner): + """x-gts-final: Sibling of a final type is unaffected. + + A~ -> B~(final) and A~ -> C~. C~ is valid because A~ is not final. + """ + + config = Config("final: sibling unaffected").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalsib.base.v1~", + {"type": "object", "properties": {"name": {"type": "string"}}}, + "register base A", + ), + _register_derived( + "gts://gts.x.testfa.finalsib.base.v1~x.testfa._.final_b.v1~", + "gts://gts.x.testfa.finalsib.base.v1~", + {"type": "object"}, + "register B (final)", + top_level={"x-gts-final": True}, + ), + _register_derived( + "gts://gts.x.testfa.finalsib.base.v1~x.testfa._.sibling_c.v1~", + "gts://gts.x.testfa.finalsib.base.v1~", + {"type": "object", "properties": {"extra": {"type": "string"}}}, + "register C (sibling) from A", + ), + _validate_schema( + "gts.x.testfa.finalsib.base.v1~x.testfa._.sibling_c.v1~", + True, + "validate C should pass - A is not final", + ), + ] + + +class TestCaseFinal_FalseIsNoop(HttpRunner): + """x-gts-final: false behaves the same as absent — derivation allowed.""" + + config = Config("final: false is noop").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalfalse.base.v1~", + { + "type": "object", + "x-gts-final": False, + "properties": {"name": {"type": "string"}}, + }, + "register base with final=false", + ), + _register_derived( + "gts://gts.x.testfa.finalfalse.base.v1~x.testfa._.derived.v1~", + "gts://gts.x.testfa.finalfalse.base.v1~", + {"type": "object"}, + "register derived from final=false base", + ), + _validate_schema( + "gts.x.testfa.finalfalse.base.v1~x.testfa._.derived.v1~", + True, + "validate derived should pass - final=false is noop", + ), + ] + + +class TestCaseFinal_NonBooleanRejected(HttpRunner): + """x-gts-final: Non-boolean value MUST be rejected on registration. + + Registering a schema with x-gts-final: "yes" should fail. + """ + + config = Config("final: non-boolean rejected").base_url(get_gts_base_url()) + teststeps = [ + Step( + RunRequest("register schema with final='yes' should be rejected") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.testfa.finalbadval.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-final": "yes", + "properties": {"name": {"type": "string"}}, + }) + .validate() + .assert_equal("status_code", 422) + .assert_equal("body.ok", False) + ), + ] + + +class TestCaseFinal_InInstanceRejected(HttpRunner): + """x-gts-final: Schema-only keyword in an instance MUST be rejected. + + An instance body containing x-gts-final should fail entity validation. + """ + + config = Config("final: in instance rejected").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalininst.base.v1~", + { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"}, + }, + }, + "register base schema", + ), + _register_instance( + { + "id": "gts.x.testfa.finalininst.base.v1~x.testfa._.item.v1", + "x-gts-final": True, + }, + "register instance with x-gts-final in body", + ), + _validate_entity( + "gts.x.testfa.finalininst.base.v1~x.testfa._.item.v1", + False, + "validate-entity instance with schema keyword should fail", + expected_entity_type="instance", + ), + ] + + +# --------------------------------------------------------------------------- +# x-gts-abstract tests +# --------------------------------------------------------------------------- + + +class TestCaseAbstract_RejectDirectInstance(HttpRunner): + """x-gts-abstract: Direct instance of abstract type MUST fail validation.""" + + config = Config("abstract: reject direct instance").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.abs.reject.v1~", + { + "type": "object", + "x-gts-abstract": True, + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register abstract base", + ), + _register_instance( + { + "id": "gts.x.testfa.abs.reject.v1~x.testfa._.item.v1", + "name": "Direct item", + }, + "register instance of abstract type", + ), + _validate_instance( + "gts.x.testfa.abs.reject.v1~x.testfa._.item.v1", + False, + "validate direct instance should fail", + expected_id="gts.x.testfa.abs.reject.v1~x.testfa._.item.v1", + ), + ] + + +class TestCaseAbstract_AllowDerivedSchema(HttpRunner): + """x-gts-abstract: Derived schema from abstract type MUST pass validation.""" + + config = Config("abstract: allow derived schema").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.abs.derive.v1~", + { + "type": "object", + "x-gts-abstract": True, + "properties": {"name": {"type": "string"}}, + }, + "register abstract base", + ), + _register_derived( + "gts://gts.x.testfa.abs.derive.v1~x.testfa._.concrete.v1~", + "gts://gts.x.testfa.abs.derive.v1~", + {"type": "object", "properties": {"extra": {"type": "string"}}}, + "register concrete derived", + ), + _validate_schema( + "gts.x.testfa.abs.derive.v1~x.testfa._.concrete.v1~", + True, + "validate derived should pass", + ), + ] + + +class TestCaseAbstract_AllowInstanceOfConcreteDerived(HttpRunner): + """x-gts-abstract: Instance of concrete derived type MUST pass. + + Abstract A~, concrete A~B~. Instance of B~ should pass. + """ + + config = Config("abstract: allow instance of concrete derived").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.abs.concinst.v1~", + { + "type": "object", + "x-gts-abstract": True, + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register abstract base", + ), + _register_derived( + "gts://gts.x.testfa.abs.concinst.v1~x.testfa._.concrete.v1~", + "gts://gts.x.testfa.abs.concinst.v1~", + {"type": "object", "properties": {"extra": {"type": "string"}}}, + "register concrete derived", + ), + _register_instance( + { + "id": "gts.x.testfa.abs.concinst.v1~x.testfa._.concrete.v1~x.testfa._.my_item.v1", + "name": "My Item", + "extra": "value", + }, + "register instance of concrete derived", + ), + _validate_instance( + "gts.x.testfa.abs.concinst.v1~x.testfa._.concrete.v1~x.testfa._.my_item.v1", + True, + "validate instance of concrete derived should pass", + ), + ] + + +class TestCaseAbstract_ChainOfAbstracts(HttpRunner): + """x-gts-abstract: Chain of abstract types — only concrete leaf allows instances. + + Abstract A~, abstract A~B~, concrete A~B~C~. + Instance of C~ passes. Instance of B~ fails. + """ + + config = Config("abstract: chain of abstracts").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.abs.chain.v1~", + { + "type": "object", + "x-gts-abstract": True, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract A", + ), + _register_derived( + "gts://gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~", + "gts://gts.x.testfa.abs.chain.v1~", + {"type": "object"}, + "register abstract B", + top_level={"x-gts-abstract": True}, + ), + _register_derived( + "gts://gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~x.testfa._.leaf.v1~", + "gts://gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~", + {"type": "object", "properties": {"extra": {"type": "string"}}}, + "register concrete C", + ), + # Instance of concrete C — should pass + _register_instance( + { + "id": "gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~x.testfa._.leaf.v1~x.testfa._.item.v1", + }, + "register instance of C", + ), + _validate_instance( + "gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~x.testfa._.leaf.v1~x.testfa._.item.v1", + True, + "validate instance of C should pass", + ), + # Instance of abstract B — should fail + _register_instance( + { + "id": "gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~x.testfa._.item_b.v1", + }, + "register instance of abstract B", + ), + _validate_instance( + "gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~x.testfa._.item_b.v1", + False, + "validate instance of abstract B should fail", + expected_id="gts.x.testfa.abs.chain.v1~x.testfa._.mid.v1~x.testfa._.item_b.v1", + ), + ] + + +class TestCaseAbstract_FalseIsNoop(HttpRunner): + """x-gts-abstract: false behaves the same as absent — instances allowed.""" + + config = Config("abstract: false is noop").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.absfalse.base.v1~", + { + "type": "object", + "x-gts-abstract": False, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with abstract=false", + ), + _register_instance( + {"id": "gts.x.testfa.absfalse.base.v1~x.testfa._.item.v1"}, + "register instance", + ), + _validate_instance( + "gts.x.testfa.absfalse.base.v1~x.testfa._.item.v1", + True, + "validate instance should pass - abstract=false is noop", + ), + ] + + +class TestCaseAbstract_NonBooleanRejected(HttpRunner): + """x-gts-abstract: Non-boolean value MUST be rejected on registration.""" + + config = Config("abstract: non-boolean rejected").base_url(get_gts_base_url()) + teststeps = [ + Step( + RunRequest("register schema with abstract=1 should be rejected") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.testfa.absbadval.base.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-abstract": 1, + "properties": {"name": {"type": "string"}}, + }) + .validate() + .assert_equal("status_code", 422) + .assert_equal("body.ok", False) + ), + ] + + +class TestCaseAbstract_InInstanceRejected(HttpRunner): + """x-gts-abstract: Schema-only keyword in an instance MUST be rejected.""" + + config = Config("abstract: in instance rejected").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.absininst.base.v1~", + { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base schema", + ), + _register_instance( + { + "id": "gts.x.testfa.absininst.base.v1~x.testfa._.item.v1", + "x-gts-abstract": True, + }, + "register instance with x-gts-abstract", + ), + _validate_entity( + "gts.x.testfa.absininst.base.v1~x.testfa._.item.v1", + False, + "validate-entity instance with schema keyword should fail", + expected_entity_type="instance", + ), + ] + + +class TestCaseAbstract_CombinedAnonInstanceRejected(HttpRunner): + """x-gts-abstract: Combined anonymous instance of abstract type MUST fail.""" + + config = Config("abstract: combined anon instance rejected").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.absanon.base.v1~", + { + "type": "object", + "x-gts-abstract": True, + "required": ["id", "type"], + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + }, + }, + "register abstract type", + ), + _register_instance( + { + "id": "gts.x.testfa.absanon.base.v1~c1d2e3f4-5678-4abc-8def-aabbccddeeff", + "type": "gts.x.testfa.absanon.base.v1~", + }, + "register combined anonymous instance of abstract type", + ), + _validate_instance( + "gts.x.testfa.absanon.base.v1~c1d2e3f4-5678-4abc-8def-aabbccddeeff", + False, + "validate combined anon instance of abstract should fail", + expected_id="gts.x.testfa.absanon.base.v1~c1d2e3f4-5678-4abc-8def-aabbccddeeff", + ), + ] + + +# --------------------------------------------------------------------------- +# Registration guard tests (?validate=true) +# --------------------------------------------------------------------------- + + +class TestCaseFinal_RegistrationGuardRejectsDerived(HttpRunner): + """x-gts-final: Registration with ?validate=true MUST reject derived from final base.""" + + config = Config("final: registration guard rejects derived").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalreg.base.v1~", + { + "type": "object", + "x-gts-final": True, + "properties": {"name": {"type": "string"}}, + }, + "register final base", + ), + Step( + RunRequest("register derived from final with validate=true should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testfa.finalreg.base.v1~x.testfa._.derived.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.testfa.finalreg.base.v1~"}, + {"type": "object", "properties": {"extra": {"type": "string"}}}, + ], + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +class TestCaseAbstract_RegistrationGuardRejectsInstance(HttpRunner): + """x-gts-abstract: Registration with ?validate=true MUST reject instance of abstract type.""" + + config = Config("abstract: registration guard rejects instance").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.absreg.base.v1~", + { + "type": "object", + "x-gts-abstract": True, + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register abstract base", + ), + Step( + RunRequest("register instance of abstract with validate=true should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "id": "gts.x.testfa.absreg.base.v1~x.testfa._.item.v1", + "name": "Direct item", + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +# --------------------------------------------------------------------------- +# Keyword placement tests +# --------------------------------------------------------------------------- + + +class TestCaseFinal_InsideAllOfRejected(HttpRunner): + """x-gts-final inside allOf MUST be rejected — keyword must be at top level. + + The keyword is placed inside an allOf entry instead of at the schema + top level. Registration with validation should reject this. + """ + + config = Config("final: inside allOf rejected").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalallof.base.v1~", + {"type": "object", "properties": {"name": {"type": "string"}}}, + "register base schema", + ), + Step( + RunRequest("register derived with x-gts-final inside allOf should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testfa.finalallof.base.v1~x.testfa._.derived.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.testfa.finalallof.base.v1~"}, + { + "type": "object", + "x-gts-final": True, + "properties": {"extra": {"type": "string"}}, + }, + ], + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +class TestCaseAbstract_InsideAllOfRejected(HttpRunner): + """x-gts-abstract inside allOf MUST be rejected — keyword must be at top level.""" + + config = Config("abstract: inside allOf rejected").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.absallof.base.v1~", + {"type": "object", "properties": {"name": {"type": "string"}}}, + "register base schema", + ), + Step( + RunRequest("register derived with x-gts-abstract inside allOf should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testfa.absallof.base.v1~x.testfa._.derived.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.testfa.absallof.base.v1~"}, + { + "type": "object", + "x-gts-abstract": True, + "properties": {"extra": {"type": "string"}}, + }, + ], + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +# --------------------------------------------------------------------------- +# Interaction tests +# --------------------------------------------------------------------------- + + +class TestCaseInteraction_BothModifiersRejected(HttpRunner): + """Both x-gts-final and x-gts-abstract on same schema MUST be rejected on registration.""" + + config = Config("interaction: both modifiers rejected").base_url(get_gts_base_url()) + teststeps = [ + Step( + RunRequest("register schema with both modifiers should be rejected") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.testfa.both.invalid.v1~", + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-final": True, + "x-gts-abstract": True, + "properties": {"name": {"type": "string"}}, + }) + .validate() + .assert_equal("status_code", 422) + .assert_equal("body.ok", False) + ), + ] + + +class TestCaseInteraction_FinalWithTraitsFullyResolved(HttpRunner): + """Final type with all traits resolved — validation passes. + + Since a final type cannot be derived from, all required traits + MUST be fully resolved on the final type itself. + """ + + config = Config("interaction: final with traits fully resolved").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finaltrait.base.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"type": "string", "default": "P30D"}, + "priority": {"type": "integer"}, + }, + "required": ["priority"], + }, + "properties": {"name": {"type": "string"}}, + }, + "register base with trait schema", + ), + _register_derived( + "gts://gts.x.testfa.finaltrait.base.v1~x.testfa._.leaf.v1~", + "gts://gts.x.testfa.finaltrait.base.v1~", + { + "type": "object", + "x-gts-traits": { + "priority": 5, + }, + }, + "register final derived with traits resolved", + top_level={"x-gts-final": True}, + ), + _validate_schema( + "gts.x.testfa.finaltrait.base.v1~x.testfa._.leaf.v1~", + True, + "validate final with resolved traits should pass", + ), + ] + + +class TestCaseInteraction_FinalWithTraitsMissing(HttpRunner): + """Final type with unresolved required traits — validation fails. + + A final type cannot delegate trait resolution to descendants, + so all required traits without defaults MUST be provided. + """ + + config = Config("interaction: final with missing traits").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.finalmiss.base.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "priority": {"type": "integer"}, + }, + "required": ["priority"], + }, + "properties": {"name": {"type": "string"}}, + }, + "register base with required trait (no default)", + ), + _register_derived( + "gts://gts.x.testfa.finalmiss.base.v1~x.testfa._.leaf.v1~", + "gts://gts.x.testfa.finalmiss.base.v1~", + { + "type": "object", + # x-gts-traits intentionally omitted — priority not resolved + }, + "register final derived without resolving traits", + top_level={"x-gts-final": True}, + ), + _validate_schema( + "gts.x.testfa.finalmiss.base.v1~x.testfa._.leaf.v1~", + False, + "validate final with missing required traits should fail", + ), + ] + + +class TestCaseInteraction_AbstractWithIncompleteTraitsOk(HttpRunner): + """Abstract type with unresolved traits — validation passes. + + Abstract types are not leaf schemas, so trait resolution completeness + is not enforced on them. + """ + + config = Config("interaction: abstract with incomplete traits ok").base_url(get_gts_base_url()) + teststeps = [ + _register( + "gts://gts.x.testfa.abstrait.base.v1~", + { + "type": "object", + "x-gts-abstract": True, + "x-gts-traits-schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "priority": {"type": "integer"}, + }, + "required": ["priority"], + }, + "properties": {"name": {"type": "string"}}, + }, + "register abstract base with required trait (no default)", + ), + _validate_schema( + "gts.x.testfa.abstrait.base.v1~", + True, + "validate abstract with incomplete traits should pass", + ), + ] + + +class TestCaseInteraction_AbstractBaseFinalDerived(HttpRunner): + """Abstract base + final derived: the complete lifecycle. + + Abstract A~ (no instances), final A~B~ (instances, no further derivation). + B has instances, B has no derived, A has no direct instances. + """ + + config = Config("interaction: abstract base + final derived").base_url(get_gts_base_url()) + teststeps = [ + # Register abstract base + _register( + "gts://gts.x.testfa.absfinal.base.v1~", + { + "type": "object", + "x-gts-abstract": True, + "required": ["id", "name"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register abstract base A", + ), + # Register concrete + final derived + _register_derived( + "gts://gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~", + "gts://gts.x.testfa.absfinal.base.v1~", + { + "type": "object", + "properties": {"extra": {"type": "string"}}, + }, + "register concrete final derived B", + top_level={"x-gts-final": True}, + ), + # B is valid as a schema + _validate_schema( + "gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~", + True, + "validate B should pass", + ), + # Instance of B — should pass (B is concrete) + _register_instance( + { + "id": "gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~x.testfa._.item.v1", + "name": "My Item", + "extra": "value", + }, + "register instance of B", + ), + _validate_instance( + "gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~x.testfa._.item.v1", + True, + "validate instance of B should pass", + ), + # Derived from B — should fail (B is final) + _register_derived( + "gts://gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~x.testfa._.sub.v1~", + "gts://gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~", + {"type": "object"}, + "attempt to derive from final B", + ), + _validate_schema( + "gts.x.testfa.absfinal.base.v1~x.testfa._.concrete.v1~x.testfa._.sub.v1~", + False, + "validate derived from final B should fail", + ), + # Direct instance of A — should fail (A is abstract) + _register_instance( + { + "id": "gts.x.testfa.absfinal.base.v1~x.testfa._.direct.v1", + "name": "Direct from abstract", + }, + "register direct instance of abstract A", + ), + _validate_instance( + "gts.x.testfa.absfinal.base.v1~x.testfa._.direct.v1", + False, + "validate direct instance of abstract A should fail", + expected_id="gts.x.testfa.absfinal.base.v1~x.testfa._.direct.v1", + ), + ] + + +if __name__ == "__main__": + TestCaseFinal_RejectDerivedSchema().test_start() From b206ff4fb106c45bd161d8bc5cdaf1b3188b279b Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 15 Apr 2026 13:07:17 +0300 Subject: [PATCH 2/2] feat: bump specification draft version to 0.9 Signed-off-by: Aviator 5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f55506a..5dca0ca 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -> **VERSION**: GTS specification draft, version 0.8 +> **VERSION**: GTS specification draft, version 0.9 # Global Type System (GTS) Specification @@ -107,6 +107,7 @@ See the [Practical Benefits for Service and Platform Vendors](#51-practical-bene | 0.8beta1 | Add OP#12 (schema vs schema validation), unified validation endpoint (/validate-entity), and clarify instance -> schema and schema -> schema validation semantics for chained GTS IDs | | 0.8beta2 | Introduce schema traits (`x-gts-traits-schema`, `x-gts-traits`) and OP#13 (schema traits validation) | | 0.8 | Add alternate combined anonymous instance identifier format | +| 0.9 | Add `x-gts-final` and `x-gts-abstract` schema modifiers; enforce final/abstract semantics in OP#6 and OP#12 | ## 1. Motivation