Skip to content

Commit 3edda03

Browse files
authored
Merge pull request #13 from KvizadSaderah/feat/gts-spec-v0.7
Feat/gts spec v0.7
2 parents 78de4c4 + 4530fde commit 3edda03

3 files changed

Lines changed: 108 additions & 26 deletions

File tree

gts/src/gts/entities.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ class GtsConfig:
6060
"id",
6161
],
6262
schema_id_fields=[
63-
"$schema",
6463
"gtsTid",
6564
"gtsType",
6665
"gtsT",
@@ -162,10 +161,7 @@ def _is_json_schema_entity(self) -> bool:
162161
return True
163162
if url.startswith("https://json-schema.org/"):
164163
return True
165-
if url.startswith("gts://"):
166-
return True
167-
if url.startswith("gts."):
168-
return True
164+
# Issue #25: strict check, no GTS IDs in $schema
169165
return False
170166

171167
def resolve_path(self, path: str) -> "GtsPathResolver":
@@ -251,8 +247,12 @@ def _extract_gts_ids_with_paths(self) -> List[Dict[str, str]]:
251247

252248
def gts_id_matcher(node: Any, path: str) -> Optional[Dict[str, str]]:
253249
"""Match GTS ID strings."""
254-
if isinstance(node, str) and GtsID.is_valid(node):
255-
return {"id": node, "sourcePath": path or "root"}
250+
if isinstance(node, str):
251+
val = node
252+
if val.startswith("gts://"):
253+
val = val[6:]
254+
if GtsID.is_valid(val):
255+
return {"id": val, "sourcePath": path or "root"}
256256
return None
257257

258258
self._walk_and_collect(self.content, found, gts_id_matcher)
@@ -265,8 +265,12 @@ def _extract_ref_strings_with_paths(self) -> List[Dict[str, str]]:
265265
def ref_matcher(node: Any, path: str) -> Optional[Dict[str, str]]:
266266
"""Match $ref properties in dict nodes."""
267267
if isinstance(node, dict) and isinstance(node.get("$ref"), str):
268+
val = node["$ref"]
269+
# Issue #32: handle gts:// prefix
270+
if val.startswith("gts://"):
271+
val = val[6:]
268272
ref_path = f"{path}.$ref" if path else "$ref"
269-
return {"id": node["$ref"], "sourcePath": ref_path}
273+
return {"id": val, "sourcePath": ref_path}
270274
return None
271275

272276
self._walk_and_collect(self.content, refs, ref_matcher)
@@ -277,7 +281,12 @@ def _get_field_value(self, field: str) -> Optional[str]:
277281
if not isinstance(self.content, dict):
278282
return None
279283
v = self.content.get(field)
280-
return v if isinstance(v, str) and v.strip() else None
284+
if isinstance(v, str) and v.strip():
285+
# Issue #31, #32: Handle gts:// prefix in fields (e.g. $id)
286+
if v.startswith("gts://"):
287+
v = v[6:]
288+
return v
289+
return None
281290

282291
def _first_non_empty_field(self, fields: List[str]) -> Optional[Tuple[str, str]]:
283292
"""Find first non-empty field, preferring valid GTS IDs."""

gts/src/gts/store.py

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ def _create_ref_resolver(self, schema: Dict[str, Any]) -> RefResolver:
182182

183183
def resolve_gts_ref(uri: str) -> Dict[str, Any]:
184184
"""Resolve a GTS ID reference to its schema content."""
185+
# Issue #32: handle gts:// prefix
186+
if uri.startswith("gts://"):
187+
uri = uri[6:]
185188
try:
186189
return self.get_schema_content(uri)
187190
except KeyError:
@@ -194,15 +197,69 @@ def resolve_gts_ref(uri: str) -> Dict[str, Any]:
194197
store[entity_id] = entity.content
195198

196199
# Create RefResolver with custom handlers
197-
resolver = RefResolver.from_schema(
198-
schema, store=store, handlers={"": resolve_gts_ref}
199-
)
200+
# Issue #32: Support "gts" scheme
201+
handlers = {"": resolve_gts_ref, "gts": resolve_gts_ref}
202+
resolver = RefResolver.from_schema(schema, store=store, handlers=handlers)
200203
return resolver
201204

202205
def items(self):
203206
"""Return all entity ID and entity pairs."""
204207
return self._by_id.items()
205208

209+
@staticmethod
210+
def _validate_schema_refs(schema: Dict[str, Any], path: str = "") -> None:
211+
"""
212+
Validate all $ref values in a schema.
213+
214+
Rules:
215+
- Local refs (starting with #) are always valid
216+
- External refs MUST use gts:// URI format
217+
- The GTS ID after gts:// must be a valid GTS identifier
218+
219+
Args:
220+
schema: Schema content to validate
221+
path: Current path in schema (for error messages)
222+
223+
Raises:
224+
ValueError: If any $ref is invalid
225+
"""
226+
if isinstance(schema, dict):
227+
# Check $ref if present
228+
if "$ref" in schema:
229+
ref_uri = schema["$ref"]
230+
if isinstance(ref_uri, str):
231+
current_path = f"{path}.$ref" if path else "$ref"
232+
233+
# Local refs (JSON Pointer) are always valid
234+
if ref_uri.startswith("#"):
235+
pass # Valid local ref
236+
# GTS refs must use gts:// URI format
237+
elif ref_uri.startswith("gts://"):
238+
gts_id = ref_uri[6:] # Strip prefix
239+
# Validate the GTS ID
240+
if not GtsID.is_valid(gts_id):
241+
raise ValueError(
242+
f"Invalid $ref at '{current_path}': '{ref_uri}' contains invalid GTS identifier '{gts_id}'"
243+
)
244+
# Any other external ref is invalid
245+
else:
246+
raise ValueError(
247+
f"Invalid $ref at '{current_path}': '{ref_uri}' must be a local ref (starting with '#') "
248+
f"or a GTS URI (starting with 'gts://')"
249+
)
250+
251+
# Recursively validate nested objects
252+
for key, value in schema.items():
253+
if key == "$ref":
254+
continue # Already validated above
255+
nested_path = f"{path}.{key}" if path else key
256+
GtsStore._validate_schema_refs(value, nested_path)
257+
258+
elif isinstance(schema, list):
259+
for idx, item in enumerate(schema):
260+
nested_path = f"{path}[{idx}]"
261+
GtsStore._validate_schema_refs(item, nested_path)
262+
206263
def _validate_schema_x_gts_refs(self, gts_id: str) -> None:
207264
"""
208265
Validate a schema's x-gts-ref fields.
@@ -256,15 +313,30 @@ def validate_schema(self, gts_id: str) -> None:
256313
if not isinstance(schema_content, dict):
257314
raise ValueError(f"Schema '{gts_id}' content must be a dictionary")
258315

316+
# Issue #25: strict check, no GTS IDs in $schema
317+
meta_schema_url = schema_content.get("$schema")
318+
if meta_schema_url and isinstance(meta_schema_url, str):
319+
if meta_schema_url.startswith("gts.") or meta_schema_url.startswith(
320+
"gts://"
321+
):
322+
raise ValueError(
323+
f"Invalid $schema URL '{meta_schema_url}': must be a standard JSON Schema URL, not a GTS ID"
324+
)
325+
259326
logging.info(f"Validating schema {gts_id}")
260327

261-
# 1. Validate against JSON Schema meta-schema
328+
# 1. Validate $ref fields - must be local (#...) or gts:// URIs
329+
# Issue #32: This validation must happen first to enforce strict $ref format
330+
self._validate_schema_refs(schema_content, "")
331+
332+
# 2. Validate x-gts-ref fields (before JSON Schema validation)
333+
self._validate_schema_x_gts_refs(gts_id)
334+
335+
# 3. Validate against JSON Schema meta-schema
262336
try:
263337
from jsonschema import Draft7Validator
264338
from jsonschema.validators import validator_for
265339

266-
# Determine which meta-schema to use based on $schema field
267-
meta_schema_url = schema_content.get("$schema")
268340
if meta_schema_url:
269341
# Use the appropriate validator for the schema version
270342
validator_class = validator_for({"$schema": meta_schema_url})
@@ -277,9 +349,6 @@ def validate_schema(self, gts_id: str) -> None:
277349
except Exception as e:
278350
raise Exception(f"JSON Schema validation failed for '{gts_id}': {str(e)}")
279351

280-
# 2. Validate x-gts-ref fields
281-
self._validate_schema_x_gts_refs(gts_id)
282-
283352
def validate_instance(
284353
self,
285354
gts_id: str,

tests/test_entities.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ def test_default_config(self):
7878
"""Test default config has expected fields."""
7979
assert "$id" in DEFAULT_GTS_CONFIG.entity_id_fields
8080
assert "gtsId" in DEFAULT_GTS_CONFIG.entity_id_fields
81-
assert "$schema" in DEFAULT_GTS_CONFIG.schema_id_fields
81+
# Issue #25: $schema should NOT be in schema_id_fields (only JSON Schema URLs allowed)
82+
assert "$schema" not in DEFAULT_GTS_CONFIG.schema_id_fields
8283
assert "gtsType" in DEFAULT_GTS_CONFIG.schema_id_fields
8384

8485

@@ -119,26 +120,28 @@ def test_entity_schema_detection_https(self):
119120
assert entity.is_schema is True
120121

121122
def test_entity_schema_detection_gts_uri(self):
122-
"""Test schema detection via gts:// URI."""
123+
"""Test that gts:// URI in $schema is NOT recognized as schema (Issue #25)."""
123124
entity = GtsEntity(
124125
content={
125126
"$schema": "gts://vendor.package.namespace.meta.v1~",
126127
"type": "object",
127128
},
128129
)
129130

130-
assert entity.is_schema is True
131+
# Issue #25: GTS IDs (even with gts:// prefix) in $schema should NOT be recognized as schemas
132+
assert entity.is_schema is False
131133

132134
def test_entity_schema_detection_gts_prefix(self):
133-
"""Test schema detection via gts. prefix."""
135+
"""Test that gts. prefix in $schema is NOT recognized as schema (Issue #25)."""
134136
entity = GtsEntity(
135137
content={
136138
"$schema": "gts.vendor.package.namespace.meta.v1~",
137139
"type": "object",
138140
},
139141
)
140142

141-
assert entity.is_schema is True
143+
# Issue #25: GTS IDs in $schema should NOT be recognized as schemas
144+
assert entity.is_schema is False
142145

143146
def test_entity_not_schema(self):
144147
"""Test non-schema entity."""
@@ -163,17 +166,18 @@ def test_entity_id_calculation(self):
163166
assert entity.selected_entity_field == "$id"
164167

165168
def test_entity_schema_id_calculation(self):
166-
"""Test schema ID calculation from content fields."""
169+
"""Test schema ID calculation from content fields (not from $schema per Issue #25)."""
167170
entity = GtsEntity(
168171
content={
169-
"$schema": "gts.vendor.package.namespace.type.v1~",
172+
"type": "gts.vendor.package.namespace.type.v1~",
170173
"name": "test",
171174
},
172175
cfg=DEFAULT_GTS_CONFIG,
173176
)
174177

178+
# Issue #25: $schema is no longer used for schema_id, use 'type' field instead
175179
assert entity.schemaId == "gts.vendor.package.namespace.type.v1~"
176-
assert entity.selected_schema_id_field == "$schema"
180+
assert entity.selected_schema_id_field == "type"
177181

178182
def test_entity_label_from_file(self):
179183
"""Test entity label derived from file."""

0 commit comments

Comments
 (0)