Skip to content
Open
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
4 changes: 4 additions & 0 deletions gts-id/src/gts_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ mod tests {
fn test_gts_id_new_with_uri_prefix() {
// Should reject gts:// prefix
assert!(GtsId::try_new("gts://x.core.v1~").is_err());
// The `gts:` form without slashes is equally invalid.
assert!(GtsId::try_new("gts:x.core.v1~").is_err());
// is_valid must agree with try_new on the gts:// form.
assert!(!GtsId::is_valid("gts://x.core.v1~"));
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion gts-macros/tests/inheritance_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ mod tests {
);

// Test INLINE resolves $refs using store (only for base type)
let mut store = GtsStore::new(None);
let mut store = GtsStore::new();
store
.register_schema(BaseEventV1::<()>::gts_type_id().as_ref(), &base_schema)
.unwrap();
Expand Down
21 changes: 3 additions & 18 deletions gts-macros/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -844,21 +844,6 @@ fn test_gts_entity_strips_uri_prefix_from_schema() {
);
}

#[test]
fn test_gts_id_does_not_accept_uri_prefix() {
// GtsId::try_new should NOT accept IDs with gts:// or gts: prefix directly
// The gts:// prefix is ONLY for JSON Schema $id field and must be stripped before parsing
assert!(GtsId::try_new("gts://gts.x.core.events.topic.v1~").is_err());
assert!(!GtsId::is_valid("gts://gts.x.core.events.topic.v1~"));

// "gts:" (without //) is also not valid
assert!(GtsId::try_new("gts:gts.x.core.events.topic.v1~").is_err());
assert!(!GtsId::is_valid("gts:gts.x.core.events.topic.v1~"));

// Regular GTS IDs should work
assert!(GtsId::is_valid("gts.x.core.events.topic.v1~"));
}

// =============================================================================
// Tests for GTS_JSON_SCHEMA_WITH_REFS and GTS_JSON_SCHEMA_INLINE
// =============================================================================
Expand Down Expand Up @@ -965,7 +950,7 @@ fn test_schema_inline_inheritance_with_parent() {
// Test base type inline resolution
use gts::GtsStore;

let mut store = GtsStore::new(None);
let mut store = GtsStore::new();
let base_schema = inheritance_tests::BaseEventV1::<()>::gts_schema_with_refs();
store
.register_schema(
Expand Down Expand Up @@ -995,7 +980,7 @@ fn test_schema_inline_inheritance_with_parent() {
fn test_runtime_schema_inline_resolution() {
use gts::GtsStore;

let mut store = GtsStore::new(None);
let mut store = GtsStore::new();

// Load only base schema - multi-segment schemas are blocked from direct method access
let base_schema = inheritance_tests::BaseEventV1::<()>::gts_schema_with_refs();
Expand Down Expand Up @@ -1063,7 +1048,7 @@ fn test_runtime_schema_inline_resolution() {
fn test_runtime_schema_inline_resolution_single_segment() {
use gts::GtsStore;

let mut store = GtsStore::new(None);
let mut store = GtsStore::new();

// Test with a single-segment schema (no inheritance)
let event_topic_schema: serde_json::Value =
Expand Down
141 changes: 101 additions & 40 deletions gts/src/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,11 @@ impl GtsEntity {
false
}

/// Extract IDs for a schema entity.
/// - `gts_id`: from `$id` field (must be `gts://` URI with GTS ID)
/// - `type_id`: the parent schema (from `$schema` field or extracted from chain)
/// - `instance_id`: same as `gts_id` for schemas
/// Extract IDs for a schema entity (Type Schema).
/// - `gts_id`: from `$id` field (must be `gts://` URI with GTS Type Identifier)
/// - `type_id`: the parent type for a chained (derived) schema; `None` for a
/// standalone (single-segment) Type Schema
/// - `instance_id`: same as the extracted GTS ID
fn extract_type_ids(&mut self, cfg: &GtsConfig) {
// Extract GTS ID from $id field
if let Some(obj) = self.content.as_object() {
Expand All @@ -236,47 +237,26 @@ impl GtsEntity {
}

let normalized = trimmed.strip_prefix(GTS_URI_PREFIX).unwrap_or(trimmed);
if GtsId::is_valid(normalized) {
self.gts_id = GtsId::try_new(normalized).ok();
if let Ok(gts_id) = GtsId::try_new(normalized) {
// A Type Schema must be keyed by a type id (ending in `~`).
if !gts_id.is_type() {
return;
}
self.gts_id = Some(gts_id);
self.instance_id = Some(normalized.to_owned());
self.selected_entity_field = Some("$id".to_owned());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// For schemas, type_id is the $schema field value
// OR for GTS schemas with chains, it's the parent type
if let Some(schema_val) = obj.get("$schema")
&& let Some(schema_str) = schema_val.as_str()
{
// Per spec: type_id MUST be a GTS Type Identifier or null and no
// longer falls back to a JSON Schema dialect URL. Only retain
// $schema values that parse as a GTS Type Identifier (chain
// ending in '~'); leave selected_type_id_field set either
// way so callers can see we looked at $schema.
if schema_str.ends_with('~') && GtsId::is_valid(schema_str) {
self.type_id = Some(schema_str.to_owned());
}
self.selected_type_id_field = Some("$schema".to_owned());
}

// For chained GTS IDs, the parent schema is the type id formed by
// every segment except the last. `GtsId::get_type_id()` reconstructs
// it correctly by joining the raw segments — which already carry their
// trailing `~` — so it avoids the double-`~` a hand-rolled
// `join("~")` would produce for chains with two or more parents.
// For derived (chained) Type Schemas, extract the parent type
// (all segments except the last). For standalone Type Schemas
// (one segment), type_id remains None.
if let Some(ref gts_id) = self.gts_id
&& gts_id.segments().len() > 1
&& let Some(parent_id) = gts_id.get_type_id()
{
// Use parent from chain as type_id when current value isn't
// already a GTS Type Identifier (e.g. $schema held a JSON
// Schema dialect URL, or no $schema was present).
let already_gts_type_id = self
.type_id
.as_ref()
.is_some_and(|s| s.ends_with('~') && GtsId::is_valid(s));
if !already_gts_type_id {
self.type_id = Some(parent_id);
}
self.type_id = Some(parent_id);
self.selected_type_id_field = Some("$id".to_owned());
}
}

Expand Down Expand Up @@ -513,6 +493,10 @@ impl GtsEntity {
result
}

/// Lenient, path-tracking discovery of every GTS-id-shaped string (not just
/// `$ref`s) for the dependency graph / display ([`Self::gts_refs`]).
/// Deliberately broader and non-failing — NOT the canonical resolvable-ref
/// definition ([`crate::schema_refs::extract_gts_refs`]); the two diverge.
fn extract_gts_ids_with_paths(&self) -> Vec<GtsRef> {
let mut found = Vec::new();

Expand All @@ -536,6 +520,11 @@ impl GtsEntity {
Self::deduplicate_by_id_and_path(found)
}

/// Lenient, path-tracking collection of every `$ref` literal (external
/// `gts://` refs normalized + local `#/...` pointers) for display
/// ([`Self::schema_refs`]). Looser and non-failing, like
/// [`Self::extract_gts_ids_with_paths`] — not the canonical
/// [`crate::schema_refs::extract_gts_refs`] used for validation/resolution.
fn extract_ref_strings_with_paths(&self) -> Vec<GtsRef> {
let mut refs = Vec::new();

Expand Down Expand Up @@ -964,10 +953,37 @@ mod tests {
assert!(entity.selected_type_id_field.is_some());
}

#[test]
fn test_chained_type_schema_derives_parent_type_id() {
// A derived (chained) Type Schema: type_id is the parent (all segments
// except the last) and is sourced from the `$id` field.
let content = json!({
"$id": "gts://gts.x.core.ns.base.v1~x.core._.derived.v1~",
"$schema": "http://json-schema.org/draft-07/schema#"
});

let cfg = GtsConfig::default();
let entity = GtsEntity::new(
None,
None,
&content,
Some(&cfg),
None,
false,
String::new(),
None,
None,
);

assert_eq!(entity.type_id, Some("gts.x.core.ns.base.v1~".to_owned()));
assert_eq!(entity.selected_type_id_field, Some("$id".to_owned()));
}

#[test]
fn test_json_entity_when_id_is_schema() {
// Type Schema must have $id field (with gts:// URI), not plain id field
let content = json!({
"id": "gts.vendor.package.namespace.type.v1.0~",
"$id": "gts://gts.vendor.package.namespace.type.v1.0~",
"$schema": "http://json-schema.org/draft-07/schema#"
});

Expand All @@ -984,8 +1000,11 @@ mod tests {
None,
);

// When entity ID itself is a schema, selected_type_id_field should be set to $schema
assert_eq!(entity.selected_type_id_field, Some("$schema".to_owned()));
// Presence of $schema field makes it a Type Schema
assert!(entity.is_schema);
assert!(entity.gts_id.is_some());
// Standalone Type Schema: type_id should be None (no parent type)
assert_eq!(entity.type_id, None);
}

// =============================================================================
Expand Down Expand Up @@ -1566,4 +1585,46 @@ mod tests {
assert!(entity.instance_id.is_none());
assert!(entity.gts_id.is_none());
}

#[test]
fn test_schema_with_instance_style_id_is_not_keyed_as_type() {
// A Type Schema must be keyed by a *type* id (ending in `~`). A schema
// whose `$id` parses as a valid GTS id but is *instance-style* (no
// trailing `~`) must hit the `is_type()` guard in `extract_type_ids`
// and NOT be adopted as the entity's type id — otherwise an
// instance-keyed `$id` would masquerade as a type.
let content = json!({
"$id": "gts://gts.vendor.package.namespace.type.v1.0~a.b.c.d.v1",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object"
});

let cfg = GtsConfig::default();
let entity = GtsEntity::new(
None,
None,
&content,
Some(&cfg),
None,
true,
String::new(),
None,
None,
);

assert!(entity.is_schema, "the $schema field makes this a schema");
assert!(
entity.gts_id.is_none(),
"an instance-style $id must not be adopted as a type id"
);
assert_ne!(
entity.selected_entity_field.as_deref(),
Some("$id"),
"the instance-style $id must not be selected as the entity id"
);
assert!(
entity.effective_id().is_none(),
"the schema must not be keyed by an instance-style $id"
);
}
}
5 changes: 4 additions & 1 deletion gts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pub mod schema_cast;
pub mod schema_compat;
pub mod schema_modifiers;
pub mod schema_narrow;
pub mod schema_refs;
pub mod schema_resolver;
pub mod schema_traits;
pub mod store;
#[doc(hidden)]
Expand All @@ -32,6 +34,7 @@ pub use schema::{
};
pub use schema_cast::{GtsEntityCastResult, SchemaCastError};
pub use schema_narrow::{NarrowError, try_narrow};
pub use schema_refs::{ExtractRefsError, InvalidRefReason, extract_gts_refs};
pub use schema_traits::{GtsTraitsSchema, inline_traits_schema_of};
pub use store::{GtsReader, GtsStore, GtsStoreQueryResult, StoreError};
pub use store::{GtsReader, GtsStore, GtsStoreQueryResult, ResolvedType, StoreError};
pub use x_gts_ref::{XGtsRefValidationError, XGtsRefValidator};
Loading
Loading