diff --git a/gts-id/src/gts_id.rs b/gts-id/src/gts_id.rs index fcc1b3f..92ab079 100644 --- a/gts-id/src/gts_id.rs +++ b/gts-id/src/gts_id.rs @@ -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] diff --git a/gts-macros/tests/inheritance_tests.rs b/gts-macros/tests/inheritance_tests.rs index 8a7b95d..c3e8b0c 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -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(); diff --git a/gts-macros/tests/integration_tests.rs b/gts-macros/tests/integration_tests.rs index 364799c..a99f4b4 100644 --- a/gts-macros/tests/integration_tests.rs +++ b/gts-macros/tests/integration_tests.rs @@ -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 // ============================================================================= @@ -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( @@ -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(); @@ -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 = diff --git a/gts/src/entities.rs b/gts/src/entities.rs index 8aef502..6d79a12 100644 --- a/gts/src/entities.rs +++ b/gts/src/entities.rs @@ -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() { @@ -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()); } } - // 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()); } } @@ -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 { let mut found = Vec::new(); @@ -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 { let mut refs = Vec::new(); @@ -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#" }); @@ -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); } // ============================================================================= @@ -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" + ); + } } diff --git a/gts/src/lib.rs b/gts/src/lib.rs index a1bbc90..c4ac37b 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -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)] @@ -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}; diff --git a/gts/src/ops.rs b/gts/src/ops.rs index ab66e4b..4346a81 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -201,10 +201,14 @@ impl GtsOps { #[must_use] pub fn new(path: Option>, config: Option, verbose: usize) -> Self { let cfg = Self::load_config(config); - let reader: Option> = path.as_ref().map(|p| { - Box::new(GtsFileReader::new(p, Some(cfg.clone()))) as Box - }); - let store = GtsStore::new(reader); + let store = match path.as_ref() { + Some(p) => { + let reader = Box::new(GtsFileReader::new(p, Some(cfg.clone()))) + as Box; + GtsStore::with_reader(reader) + } + None => GtsStore::new(), + }; GtsOps { verbose, @@ -271,7 +275,7 @@ impl GtsOps { self.path = Some(path.to_vec()); let reader = Box::new(GtsFileReader::new(path, Some(self.cfg.clone()))) as Box; - self.store = GtsStore::new(Some(reader)); + self.store = GtsStore::with_reader(reader); } fn get_details(&mut self, entity: &GtsEntity) -> String { @@ -329,24 +333,13 @@ impl GtsOps { }; }; - // Register the entity first - if let Err(e) = self.store.register(entity.clone()) { - return GtsAddEntityResult { - ok: false, - id: String::new(), - type_id: None, - is_type_schema: entity.is_schema, - error: format!( - "Unable to register entity: {e}\n{}", - self.get_details(&entity) - ), - }; - } - - // Validate schema modifiers (x-gts-final, x-gts-abstract): types, - // mutual exclusion, and that they appear only at the schema top level. + // Validate GTS extension keywords (x-gts-final/x-gts-abstract format and + // mutual exclusion; x-gts-traits/x-gts-traits-schema placement) — raw + // structural check enforced at every ingest, regardless of `validate`. + // Pure check, so run it before `register` to avoid leaving a malformed + // schema in the store. if entity.is_schema - && let Err(e) = crate::schema_modifiers::validate_schema_modifiers(&entity.content) + && let Err(e) = crate::schema_modifiers::validate_gts_keywords(&entity.content) { return GtsAddEntityResult { ok: false, @@ -357,66 +350,46 @@ impl GtsOps { }; } - // Validate trait keyword placement (x-gts-traits, x-gts-traits-schema): - // these are type-level keywords and MUST appear only at the schema top - // level — nesting them inside a subschema is rejected (GTS spec §9.7.1/§9.11). - if entity.is_schema - && let Err(e) = crate::schema_modifiers::validate_trait_placement(&entity.content) - { - return GtsAddEntityResult { - ok: false, - id: String::new(), - type_id: None, - is_type_schema: entity.is_schema, - error: e, - }; - } - - // Always validate schemas - if entity.is_schema - && let Err(e) = self.store.validate_schema(&entity_id) - { + // Register the entity + if let Err(e) = self.store.register(entity.clone()) { return GtsAddEntityResult { ok: false, id: String::new(), type_id: None, is_type_schema: entity.is_schema, error: format!( - "Schema validation failed: {e}\n{}", + "Unable to register entity: {e}\n{}", self.get_details(&entity) ), }; } - // If validation is requested, run full chain/traits validation for schemas - // and instance validation for instances. - if validate && entity.is_schema { - if let Err(e) = self.store.validate_schema_chain(&entity_id) { - return GtsAddEntityResult { - ok: false, - id: String::new(), - type_id: None, - is_type_schema: entity.is_schema, - error: format!( - "Schema chain validation failed: {e}\n{}", - self.get_details(&entity) - ), - }; - } - if let Err(e) = self.store.validate_schema_traits(&entity_id) { + // Validate schemas. Without `validate` we only check `$ref`/`x-gts-ref` + // structure — no dependency resolution, so forward-reference batches can + // be registered before their targets exist. With `validate` we run the + // full pipeline (refs, chain, resolve, meta-compile, traits) via + // `validate_schema`, discarding the resolved artifacts. + if entity.is_schema { + let validation = if validate { + self.store.validate_schema(&entity_id).map(|_| ()) + } else { + self.store.validate_schema_refs(&entity_id) + }; + if let Err(e) = validation { return GtsAddEntityResult { ok: false, id: String::new(), type_id: None, is_type_schema: entity.is_schema, error: format!( - "Schema traits validation failed: {e}\n{}", + "Schema validation failed: {e}\n{}", self.get_details(&entity) ), }; } } + // Instance validation when requested. if validate && !entity.is_schema { if let Err(e) = crate::schema_modifiers::validate_instance_modifiers(&entity.content) { return GtsAddEntityResult { @@ -662,27 +635,11 @@ impl GtsOps { } pub fn validate_schema(&mut self, gts_id: &str) -> GtsValidationResult { - // First run basic schema validation (meta-schema, refs, etc.) - if let Err(e) = self.store.validate_schema(gts_id) { - return GtsValidationResult { - id: gts_id.to_owned(), - ok: false, - error: e.to_string(), - }; - } - - // Then run schema-vs-schema chain validation (OP#12) - if let Err(e) = self.store.validate_schema_chain(gts_id) { - return GtsValidationResult { - id: gts_id.to_owned(), - ok: false, - error: e.to_string(), - }; - } - - // Then run schema traits validation (OP#13) - match self.store.validate_schema_traits(gts_id) { - Ok(()) => GtsValidationResult { + // Full pipeline lives in `GtsStore::validate_schema` (refs → chain → + // resolve → meta-compile → traits); we only need pass/fail here, so the + // resolved artifacts are discarded. + match self.store.validate_schema(gts_id) { + Ok(_) => GtsValidationResult { id: gts_id.to_owned(), ok: true, error: String::new(), @@ -695,25 +652,104 @@ impl GtsOps { } } - pub fn validate_entity(&mut self, gts_id: &str) -> GtsEntityValidationResult { - if gts_id.ends_with('~') { - // Validate schema modifiers (x-gts-final, x-gts-abstract): types, - // mutual exclusion, and top-level placement. - if let Some(entity) = self.store.get(gts_id) - && let Err(e) = crate::schema_modifiers::validate_schema_modifiers(&entity.content) - { - return GtsEntityValidationResult { - id: gts_id.to_owned(), - ok: false, - entity_type: "schema".to_owned(), - error: e, - }; + /// OP#13 **entity-level** check for `/validate-entity`, stricter than the + /// `/validate-type-schema` conformance run (`GtsStore::validate_schema`). + /// A GTS Type Schema is a valid standalone entity only if, on top of trait + /// conformance: + /// + /// 1. every `x-gts-traits-schema` in the chain is **closed** + /// (`additionalProperties: false`) — an open trait surface means the type + /// is designed to be extended, not deployed standalone; and + /// 2. if any trait-schema is declared, concrete `x-gts-traits` values are + /// actually provided somewhere in the chain. + /// + /// Abstract types are exempt (templates — descendants close the surface). + /// This delta is pinned by the gts-spec conformance suite (the + /// `/validate-entity`-only failure cases); the store stays the trait + /// resolution/conformance engine and this entity policy lives in ops. + /// + /// # Errors + /// Returns the human-readable reason the type is not a valid standalone + /// entity (or a trait-resolution error from the store). + fn check_entity_traits(&mut self, gts_id: &str) -> Result<(), String> { + // Abstract types are templates — exempt from the standalone-entity check. + if self + .store + .get(gts_id) + .is_some_and(|e| GtsStore::content_is_abstract(&e.content)) + { + return Ok(()); + } + + let traits = self + .store + .effective_traits(gts_id) + .map_err(|e| e.to_string())?; + + if traits.resolved_trait_schemas.is_empty() { + return Ok(()); + } + + // If trait schemas exist but no trait values are provided, the entity + // is incomplete. + if traits + .merged_traits + .as_object() + .is_none_or(serde_json::Map::is_empty) + { + return Err( + "Entity defines x-gts-traits-schema but no x-gts-traits values are provided" + .to_owned(), + ); + } + + // Each trait schema must be closed: `additionalProperties: false` at the + // top level or within any `allOf` branch. A boolean schema is not closed + // and must be rejected, not silently skipped by an `as_object()` guard. + for ts in &traits.resolved_trait_schemas { + if !Self::trait_schema_is_closed(ts) { + return Err("Entity trait schema must set additionalProperties: false \ + to be a valid standalone entity" + .to_owned()); } + } + + Ok(()) + } + + /// `true` when a resolved trait schema closes its surface with + /// `additionalProperties: false` — at the top level or in any `allOf` branch + /// (the resolver preserves `allOf`, so closedness reached via + /// `allOf: [{$ref closed_type}]` lives inside a branch). + fn trait_schema_is_closed(schema: &Value) -> bool { + Self::trait_schema_is_closed_rec(schema, 0) + } + + fn trait_schema_is_closed_rec(schema: &Value, depth: usize) -> bool { + // Bound recursion to guard against a pathological `allOf` nesting. + const MAX_DEPTH: usize = 64; + if depth >= MAX_DEPTH { + return false; + } + let Some(obj) = schema.as_object() else { + return false; + }; + if obj.get("additionalProperties") == Some(&Value::Bool(false)) { + return true; + } + matches!( + obj.get("allOf"), + Some(Value::Array(branches)) + if branches.iter().any(|b| Self::trait_schema_is_closed_rec(b, depth + 1)) + ) + } - // Validate trait keyword placement (x-gts-traits, - // x-gts-traits-schema) — top-level only (GTS spec §9.7.1/§9.11). + pub fn validate_entity(&mut self, gts_id: &str) -> GtsEntityValidationResult { + if gts_id.ends_with('~') { + // Validate GTS extension keywords (x-gts-final/x-gts-abstract format + // and mutual exclusion; x-gts-traits/x-gts-traits-schema placement). if let Some(entity) = self.store.get(gts_id) - && let Err(e) = crate::schema_modifiers::validate_trait_placement(&entity.content) + && let Err(e) = crate::schema_modifiers::validate_gts_keywords(&entity.content) { return GtsEntityValidationResult { id: gts_id.to_owned(), @@ -733,16 +769,15 @@ impl GtsOps { }; } - // Entity validation requires trait schemas to be "closed" — i.e. - // the effective trait schema must set `additionalProperties: false`. - // An open trait schema means the entity is designed to be extended - // and is not a valid standalone entity. - if let Err(e) = self.store.validate_entity_traits(gts_id) { + // Beyond type-schema validation, a standalone entity must have a + // closed, resolved trait surface (pinned by the conformance suite's + // /validate-entity cases). + if let Err(e) = self.check_entity_traits(gts_id) { return GtsEntityValidationResult { id: gts_id.to_owned(), ok: false, entity_type: "schema".to_owned(), - error: e.to_string(), + error: e, }; } @@ -911,7 +946,6 @@ impl GtsOps { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::gts::GtsId; use serde_json::json; #[test] @@ -1000,14 +1034,6 @@ mod tests { assert!(result.results.is_empty()); } - #[test] - fn test_gts_id_validation() { - assert!(!GtsId::is_valid("gts.vendor.package.namespace.type.v1.0")); // Single-segment instance - should be invalid - assert!(GtsId::is_valid("gts.vendor.package.namespace.type.v1.0~")); // Single-segment type - should be valid - assert!(!GtsId::is_valid("invalid")); - assert!(!GtsId::is_valid("")); - } - #[test] fn test_cast_entity_to_schema() { let mut ops = GtsOps::new(None, None, 0); @@ -3706,4 +3732,205 @@ mod tests { assert_eq!(result.id, "gts.test.get.entity.success.v1~"); assert!(result.is_type_schema); } + + #[test] + fn test_op13_entity_traits_abstract_base_skips_completeness() { + // gts-spec §9.7.5 / §9.11.4 (ADR-0003): a type marked `x-gts-abstract: true` + // is exempt from the entity-level check. It may declare an + // `x-gts-traits-schema` without resolving any `x-gts-traits` values — + // concrete descendants close the required traits. + let mut ops = GtsOps::new(None, None, 0); + ops.store + .register_schema( + "gts.x.test13.abs.base.v1~", + &json!({ + "$id": "gts://gts.x.test13.abs.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-abstract": true, + "x-gts-traits-schema": { + "type": "object", + "additionalProperties": false, + "properties": {"topicRef": {"type": "string"}} + }, + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register abstract base"); + + assert!( + ops.check_entity_traits("gts.x.test13.abs.base.v1~").is_ok(), + "Abstract base must be exempt from the entity-level check" + ); + } + + #[test] + fn test_op13_entity_traits_non_abstract_base_without_values_fails() { + // A non-abstract type that declares a trait schema but supplies no + // `x-gts-traits` values anywhere in its chain is not a standalone entity. + let mut ops = GtsOps::new(None, None, 0); + ops.store + .register_schema( + "gts.x.test13.conc.base.v1~", + &json!({ + "$id": "gts://gts.x.test13.conc.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "additionalProperties": false, + "properties": {"topicRef": {"type": "string"}} + }, + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register concrete base"); + + assert!( + ops.check_entity_traits("gts.x.test13.conc.base.v1~") + .is_err(), + "Non-abstract base with no trait values must fail the entity check" + ); + } + + #[test] + fn test_op13_entity_traits_open_schema_not_standalone() { + // A trait schema that is not closed (no `additionalProperties: false`) + // signals the type is designed to be extended, not deployed standalone — + // even when concrete trait values are supplied. + let mut ops = GtsOps::new(None, None, 0); + ops.store + .register_schema( + "gts.x.test13.open.base.v1~", + &json!({ + "$id": "gts://gts.x.test13.open.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}} + }, + "x-gts-traits": {"topicRef": "events"}, + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register open base"); + + assert!( + ops.check_entity_traits("gts.x.test13.open.base.v1~") + .is_err(), + "Open trait schema (no additionalProperties:false) is not a valid standalone entity" + ); + } + + #[test] + fn test_op13_entity_traits_closedness_via_referenced_schema() { + // The closed trait surface comes through an allOf + $ref to another GTS + // type whose body carries `additionalProperties: false`. The resolver + // preserves the `allOf` (inlining the `$ref` in place), so the + // standalone-entity closedness check must see through `allOf` branches + // rather than expecting a flattened top-level `additionalProperties`. + let mut ops = GtsOps::new(None, None, 0); + ops.store + .register_schema( + "gts.x.test13.refclosed.traitdef.v1~", + &json!({ + "$id": "gts://gts.x.test13.refclosed.traitdef.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "additionalProperties": false + }), + ) + .expect("register trait def"); + ops.store + .register_schema( + "gts.x.test13.refclosed.base.v1~", + &json!({ + "$id": "gts://gts.x.test13.refclosed.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "allOf": [{"$ref": "gts://gts.x.test13.refclosed.traitdef.v1~"}] + }, + "x-gts-traits": {"topicRef": "events"}, + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register base"); + + assert!( + ops.check_entity_traits("gts.x.test13.refclosed.base.v1~") + .is_ok(), + "closed trait schema reached via allOf + $ref must be accepted" + ); + } + + #[test] + fn test_op13_entity_traits_boolean_schema_rejected() { + // A boolean `x-gts-traits-schema` (here `true`) carries no + // `additionalProperties: false`, so it is not closed and must be + // rejected — not silently skipped by the `as_object()` guard in + // `trait_schema_is_closed_rec`. + let mut ops = GtsOps::new(None, None, 0); + ops.store + .register_schema( + "gts.x.test13.boolean.base.v1~", + &json!({ + "$id": "gts://gts.x.test13.boolean.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": true, + "x-gts-traits": {"topicRef": "events"}, + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register boolean-trait base"); + + let err = ops + .check_entity_traits("gts.x.test13.boolean.base.v1~") + .expect_err("a boolean trait schema is not closed and must be rejected"); + assert!( + err.contains("additionalProperties"), + "error must explain the closedness requirement, got: {err}" + ); + } + + #[test] + fn test_validate_entity_reports_trait_failure() { + // The public `validate_entity` path must surface a `check_entity_traits` + // failure as `ok: false` with the explanatory error — not just return + // `ok: true` for the happy path. An open trait schema (no + // `additionalProperties: false`) with concrete trait values passes + // type-schema validation but is not a valid standalone entity. + let mut ops = GtsOps::new(None, None, 0); + ops.store + .register_schema( + "gts.x.test13.validate.open.v1~", + &json!({ + "$id": "gts://gts.x.test13.validate.open.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}} + }, + "x-gts-traits": {"topicRef": "events"}, + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register open base"); + + let result = ops.validate_entity("gts.x.test13.validate.open.v1~"); + assert!( + !result.ok, + "an open trait schema must fail validate_entity, got ok=true" + ); + assert!( + result.error.contains("additionalProperties"), + "validate_entity must surface the closedness error, got: {}", + result.error + ); + } } diff --git a/gts/src/schema_compat.rs b/gts/src/schema_compat.rs index 1bc0f01..fb54ad7 100644 --- a/gts/src/schema_compat.rs +++ b/gts/src/schema_compat.rs @@ -20,6 +20,27 @@ pub(crate) struct EffectiveSchema { pub additional_properties: Option, } +/// Folds an `additionalProperties` value into an accumulator using a +/// closedness-preserving lattice: `false` (closed) is strongest, an object +/// (partial constraint) is in the middle, and `true` (open) is weakest. +/// +/// This mirrors `allOf` composition, where the schema stays closed if **any** +/// branch sets `additionalProperties: false`, so a permissive overlay can never +/// loosen a closed base. Used both when flattening `allOf` during ref +/// resolution and when extracting the effective schema for compatibility checks. +pub(crate) fn merge_additional_properties_constraint( + current: &mut Option, + candidate: &Value, +) { + match (current.as_ref(), candidate) { + (Some(Value::Bool(false)), _) => {} + (_, Value::Bool(false)) => *current = Some(Value::Bool(false)), + (None | Some(Value::Bool(true)), _) => *current = Some(candidate.clone()), + (Some(_), Value::Bool(true)) => {} + (Some(_), _) => *current = Some(candidate.clone()), + } +} + /// Extracts the effective schema properties, required fields, and /// `additionalProperties` from a fully-resolved JSON Schema value. /// @@ -51,7 +72,7 @@ pub(crate) fn extract_effective_schema(schema: &Value) -> EffectiveSchema { // additionalProperties if let Some(ap) = map.get("additionalProperties") { - eff.additional_properties = Some(ap.clone()); + merge_additional_properties_constraint(&mut eff.additional_properties, ap); } // allOf – merge from all items (for schemas that weren't fully flattened) @@ -60,8 +81,8 @@ pub(crate) fn extract_effective_schema(schema: &Value) -> EffectiveSchema { let item_eff = extract_effective_schema(item); eff.properties.extend(item_eff.properties); eff.required.extend(item_eff.required); - if item_eff.additional_properties.is_some() { - eff.additional_properties = item_eff.additional_properties; + if let Some(ap) = item_eff.additional_properties { + merge_additional_properties_constraint(&mut eff.additional_properties, &ap); } } } @@ -115,16 +136,14 @@ pub(crate) fn validate_schema_compatibility( } } - // Check if derived loosens additionalProperties constraint. + // Check if a direct derived schema loosens additionalProperties constraint. // // Derived "loosens" only when it *explicitly* declares a permissive - // `additionalProperties` at its own root. Omitting the keyword is - // **not** loosening when derived is composed as - // `allOf: [{$ref: closed_base}, overlay]`: across JSON Schema - // dialects, `additionalProperties` only inspects sibling - // `properties` at the same level — but the base's - // `additionalProperties: false` still applies to the same instance - // via `$ref`/`allOf` composition, so the contract is inherited. + // `additionalProperties` without a closed constraint surviving through + // allOf composition. Omitting the keyword, or composing a permissive + // overlay with a closed base, is **not** loosening: across JSON Schema + // dialects, the base's `additionalProperties: false` still applies to + // the same instance via `$ref`/`allOf` composition. // // The per-property loop above already catches the only structurally // dangerous case (derived adds a new top-level property that base @@ -594,6 +613,28 @@ mod tests { assert!(eff.required.contains("y")); } + #[test] + fn test_extract_allof_additional_properties_false_wins_over_true() { + let schema = json!({ + "type": "object", + "additionalProperties": true, + "allOf": [ + { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": false + }, + { + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": true + } + ] + }); + let eff = extract_effective_schema(&schema); + assert_eq!(eff.additional_properties, Some(Value::Bool(false))); + } + // -- validate_schema_compatibility ------------------------------------ #[test] @@ -751,9 +792,8 @@ mod tests { #[test] fn test_additional_properties_explicit_true_still_loosens() { - // Explicit `additionalProperties: true` at derived root *is* - // loosening even with allOf+$ref to a closed base, because the - // derived author actively asserts a permissive root. + // A direct derived schema that has no inherited closed branch and + // explicitly says `additionalProperties: true` loosens a closed base. let base = extract_effective_schema(&json!({ "type": "object", "properties": {"a": {"type": "string"}}, diff --git a/gts/src/schema_modifiers.rs b/gts/src/schema_modifiers.rs index d171783..46509ea 100644 --- a/gts/src/schema_modifiers.rs +++ b/gts/src/schema_modifiers.rs @@ -122,6 +122,27 @@ pub fn validate_trait_placement(content: &Value) -> Result<(), String> { Ok(()) } +/// Validate every GTS extension keyword carried by a *schema* document — both +/// format and placement: +/// - `x-gts-final` / `x-gts-abstract`: boolean, mutually exclusive, top-level +/// only ([`validate_schema_modifiers`]); +/// - `x-gts-traits` / `x-gts-traits-schema`: top-level only +/// ([`validate_trait_placement`]). +/// +/// Pure structural check on raw content (no `$ref` resolution), so it is the +/// natural companion to ref validation: both gate a schema before any +/// resolution or cross-schema work. The single entry point used by both the +/// ingest path and [`crate::store::GtsStore::validate_schema`]. +/// +/// # Errors +/// Returns the human-readable reason the first malformed or misplaced keyword +/// fails. +pub fn validate_gts_keywords(content: &Value) -> Result<(), String> { + validate_schema_modifiers(content)?; + validate_trait_placement(content)?; + Ok(()) +} + /// Check that schema-only keywords (`x-gts-final`, `x-gts-abstract`, /// `x-gts-traits-schema`, `x-gts-traits`) do not appear anywhere in instance /// content. Per GTS spec § 9.7.1 and § 9.11.1 these annotations are only @@ -543,7 +564,7 @@ mod tests { #[test] fn test_final_reject_derived_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -574,7 +595,7 @@ mod tests { #[test] fn test_final_allow_well_known_instance() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -605,7 +626,7 @@ mod tests { #[test] fn test_final_mid_chain() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -651,7 +672,7 @@ mod tests { #[test] fn test_final_sibling_unaffected() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -693,7 +714,7 @@ mod tests { #[test] fn test_final_false_is_noop() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -728,7 +749,7 @@ mod tests { #[test] fn test_abstract_reject_direct_instance() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -757,7 +778,7 @@ mod tests { #[test] fn test_abstract_allow_derived_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -790,7 +811,7 @@ mod tests { #[test] fn test_abstract_allow_instance_of_concrete_derived() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -835,7 +856,7 @@ mod tests { #[test] fn test_abstract_chain_of_abstracts() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -900,7 +921,7 @@ mod tests { #[test] fn test_abstract_false_is_noop() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ @@ -929,7 +950,7 @@ mod tests { #[test] fn test_abstract_base_final_derived() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); reg_schema( &mut store, json!({ diff --git a/gts/src/schema_refs.rs b/gts/src/schema_refs.rs new file mode 100644 index 0000000..354de41 --- /dev/null +++ b/gts/src/schema_refs.rs @@ -0,0 +1,376 @@ +use serde_json::Value; +use std::collections::BTreeSet; + +use crate::gts::{GTS_URI_PREFIX, GtsTypeId}; + +/// Why a single `$ref` string is not a valid GTS reference. +/// +/// Carried by [`ExtractRefsError::InvalidRef`] and rendered into the +/// path-tracked message the store surfaces as `StoreError::InvalidRef`. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum InvalidRefReason { + #[error("must be a local ref (starting with '#') or a GTS URI (starting with 'gts://')")] + NotGtsUri, + #[error("must reference a GTS type id (a valid identifier ending with '~'), got '{0}'")] + InvalidTypeId(String), + #[error( + "has an unsupported fragment '#{0}'; only an empty fragment or a '/'-prefixed JSON \ + Pointer is allowed" + )] + UnsupportedFragment(String), +} + +/// Failure modes of [`extract_gts_refs`]. Store-independent, like the module. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ExtractRefsError { + #[error("at '{path}': '{ref_uri}' {reason}")] + InvalidRef { + path: String, + ref_uri: String, + reason: InvalidRefReason, + }, + #[error("schema nests deeper than the maximum scan depth of {0}")] + TooDeep(usize), +} + +/// How a single `$ref` value is interpreted under GTS rules. +enum RefKind<'a> { + /// Internal JSON Pointer (`#`, `#/...`); not an external dependency. + Local, + /// External GTS type dependency: the canonical id (scheme + fragment + /// stripped). + External { id: &'a str }, +} + +/// The single, canonical interpretation of a `$ref` string. Both schema +/// validation ([`crate::store::GtsStore::validate_schema_refs`], via +/// [`extract_gts_refs`]) and dependency extraction share this definition so the +/// two cannot drift. +/// +/// External references MUST use the `gts://` scheme; a bare id (no scheme) is +/// rejected, matching what the store is able to register and retrieve. +fn classify_ref(ref_uri: &str) -> Result, InvalidRefReason> { + // Local JSON Pointers (`#`, `#/...`) are always valid and not external edges. + if ref_uri.starts_with('#') { + return Ok(RefKind::Local); + } + + // Everything else must be a `gts://` URI; a bare id or any other scheme is + // not a ref the store can resolve. + let Some(rest) = ref_uri.strip_prefix(GTS_URI_PREFIX) else { + return Err(InvalidRefReason::NotGtsUri); + }; + + // A GTS `$ref` may carry a JSON Pointer fragment selecting a sub-schema of + // the target document (e.g. `gts://...~#/x-gts-traits-schema`). Validate the + // id portion as a type id and require any fragment to be empty or a + // `/`-prefixed JSON Pointer - the exact shapes `resolve_schema_refs_inner` + // is able to dereference. + let (id, fragment) = match rest.split_once('#') { + Some((id, frag)) => (id, Some(frag)), + None => (rest, None), + }; + if GtsTypeId::try_new(id).is_err() { + return Err(InvalidRefReason::InvalidTypeId(id.to_owned())); + } + if let Some(frag) = fragment + && !frag.is_empty() + && !frag.starts_with('/') + { + return Err(InvalidRefReason::UnsupportedFragment(frag.to_owned())); + } + Ok(RefKind::External { id }) +} + +/// Direct external type references of a **raw** schema - the `$ref` +/// dependency edges of this node, store-independent and pure. +/// +/// Recurses the whole value, so the type body, `$defs`, combinators, and +/// `x-gts-traits-schema` are all covered (raw, before `allOf` flattening +/// drops `x-gts-*`). For every external `$ref` it returns the canonical GTS id: +/// the `gts://` scheme prefix and any `#...` pointer fragment are stripped. +/// +/// Every `$ref` is validated against the single canonical definition +/// ([`classify_ref`]): external refs must use the `gts://` scheme and resolve to +/// a valid GTS **type** id (a valid identifier ending with `~`). A malformed or +/// bare-id ref is rejected up front rather than surfacing later as a failed +/// lookup, with a path-tracked error. +/// +/// Excludes internal `#/...` references (e.g. `#/$defs/...`) and `x-gts-ref` +/// (a constraint on instance values, not a schema dependency to inline). Does +/// NOT include the `$id`-chain parent - that edge is derived structurally from +/// the id, not from content. +/// +/// This is the **canonical, strict** ref definition for validation/resolution. +/// The lenient `GtsEntity` walkers (`extract_gts_ids_with_paths`, +/// `extract_ref_strings_with_paths`) feed the dependency graph / display +/// instead and intentionally diverge — they must not be conflated with this. +/// +/// # Errors +/// [`ExtractRefsError::InvalidRef`] if a `$ref` is not a valid GTS reference +/// (see [`InvalidRefReason`]); [`ExtractRefsError::TooDeep`] if the schema nests past +/// the scan cap (a deeper ref could not be validated). +pub fn extract_gts_refs(schema: &Value) -> Result, ExtractRefsError> { + let mut refs = BTreeSet::new(); + collect_gts_refs(schema, "", 0, &mut refs)?; + Ok(refs) +} + +fn collect_gts_refs( + value: &Value, + path: &str, + depth: usize, + out: &mut BTreeSet, +) -> Result<(), ExtractRefsError> { + const MAX_REF_SCAN_DEPTH: usize = 64; + if depth > MAX_REF_SCAN_DEPTH { + return Err(ExtractRefsError::TooDeep(MAX_REF_SCAN_DEPTH)); + } + + match value { + Value::Object(map) => { + if let Some(Value::String(ref_uri)) = map.get("$ref") { + let ref_path = if path.is_empty() { + "$ref".to_owned() + } else { + format!("{path}.$ref") + }; + match classify_ref(ref_uri) { + Ok(RefKind::Local) => {} + Ok(RefKind::External { id }) => { + out.insert(id.to_owned()); + } + Err(reason) => { + return Err(ExtractRefsError::InvalidRef { + path: ref_path, + ref_uri: ref_uri.clone(), + reason, + }); + } + } + } + for (key, v) in map { + if key == "$ref" { + continue; // already classified above + } + // Data-valued keywords carry instance data, not subschemas, so a + // `$ref` nested inside them is literal data, not a dependency edge. + if matches!(key.as_str(), "const" | "default" | "examples" | "enum") { + continue; + } + let nested = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + collect_gts_refs(v, &nested, depth + 1, out)?; + } + } + Value::Array(items) => { + for (idx, v) in items.iter().enumerate() { + let nested = format!("{path}[{idx}]"); + collect_gts_refs(v, &nested, depth + 1, out)?; + } + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extract_gts_refs_body_traits_and_normalization() { + let schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + // gts:// scheme ref in the body + "a": {"$ref": "gts://gts.x.dep.ns.a.v1~"}, + // another gts:// scheme ref + "b": {"$ref": "gts://gts.x.dep.ns.b.v1~"}, + // ref with a pointer fragment - fragment stripped to the id + "c": {"$ref": "gts://gts.x.dep.ns.c.v1~#/properties/inner"}, + // internal JSON Pointer ref - excluded + "d": {"$ref": "#/$defs/Local"}, + // x-gts-ref is an instance-value constraint, not a schema dep + "e": {"type": "string", "x-gts-ref": "gts.x.notdep.ns.e.v1~"}, + // duplicate of `a` - must dedupe + "f": {"$ref": "gts://gts.x.dep.ns.a.v1~"} + }, + // refs nested in combinators must be found + "allOf": [{"$ref": "gts://gts.x.dep.ns.allof.v1~"}], + // refs inside x-gts-traits-schema must be found + "x-gts-traits-schema": { + "type": "object", + "properties": {"t": {"$ref": "gts://gts.x.dep.ns.trait.v1~"}} + } + }); + + let refs = extract_gts_refs(&schema).unwrap(); + let expected: BTreeSet = [ + "gts.x.dep.ns.a.v1~", + "gts.x.dep.ns.b.v1~", + "gts.x.dep.ns.c.v1~", + "gts.x.dep.ns.allof.v1~", + "gts.x.dep.ns.trait.v1~", + ] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + + assert_eq!(refs, expected); + } + + #[test] + fn extract_gts_refs_none() { + let schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object"} + }); + assert!(extract_gts_refs(&schema).unwrap().is_empty()); + } + + #[test] + fn extract_gts_refs_ignores_data_valued_keywords() { + // A `$ref` inside `const`/`default`/`examples`/`enum` is data, not a + // dependency edge, so it must not be classified even when malformed. + let schema = json!({ + "type": "object", + "properties": { + "a": {"const": {"$ref": "not-a-schema-ref"}}, + "b": {"default": {"nested": {"$ref": "also-not-a-ref"}}}, + "c": {"enum": [{"$ref": "still-data"}]}, + "d": {"examples": [{"$ref": "example-data"}]}, + // A real schema ref alongside the data must still be found. + "e": {"$ref": "gts://gts.x.dep.ns.real.v1~"} + } + }); + let refs = extract_gts_refs(&schema).unwrap(); + let expected: BTreeSet = ["gts.x.dep.ns.real.v1~".to_owned()].into_iter().collect(); + assert_eq!(refs, expected); + } + + #[test] + fn extract_gts_refs_rejects_bare_id() { + // A bare GTS id (no `gts://` scheme) is not a ref the store can resolve; + // it must be rejected, matching `validate_ref_uris`. + let bare_ref = json!({ + "type": "object", + "properties": {"a": {"$ref": "gts.x.dep.ns.a.v1~"}} + }); + assert!(matches!( + extract_gts_refs(&bare_ref), + Err(ExtractRefsError::InvalidRef { + reason: InvalidRefReason::NotGtsUri, + .. + }) + )); + } + + #[test] + fn extract_gts_refs_rejects_invalid() { + let instance_ref = json!({ + "type": "object", + "properties": {"a": {"$ref": "gts://gts.x.dep.ns.a.v1"}} + }); + assert!(matches!( + extract_gts_refs(&instance_ref), + Err(ExtractRefsError::InvalidRef { + reason: InvalidRefReason::InvalidTypeId(_), + .. + }) + )); + + let garbage_ref = json!({ + "type": "object", + "properties": {"a": {"$ref": "gts://not a gts id"}} + }); + assert!(matches!( + extract_gts_refs(&garbage_ref), + Err(ExtractRefsError::InvalidRef { + reason: InvalidRefReason::InvalidTypeId(_), + .. + }) + )); + } + + #[test] + fn extract_gts_refs_reports_path() { + // An invalid ref must carry the JSON path to the offending `$ref`. + let schema = json!({ + "properties": {"order": {"$ref": "invalid-ref"}} + }); + let err = extract_gts_refs(&schema).unwrap_err(); + assert!( + err.to_string().contains("properties.order.$ref"), + "error should report the path, got: {err}" + ); + } + + #[test] + fn extract_gts_refs_rejects_unsupported_fragment() { + let schema = json!({"$ref": "gts://gts.x.dep.ns.a.v1~#bad"}); + assert!(matches!( + extract_gts_refs(&schema), + Err(ExtractRefsError::InvalidRef { + reason: InvalidRefReason::UnsupportedFragment(_), + .. + }) + )); + } + + #[test] + fn extract_gts_refs_rejects_too_deep() { + // Nesting past the scan cap must error rather than silently pass. + let mut schema = json!({"type": "object"}); + for _ in 0..70 { + schema = json!({"properties": {"nested": schema}}); + } + assert_eq!( + extract_gts_refs(&schema), + Err(ExtractRefsError::TooDeep(64)) + ); + } + + #[test] + fn extract_gts_refs_found_in_arrays() { + // Refs appearing directly as array elements must be collected. + let schema = json!({ + "oneOf": [ + {"$ref": "gts://gts.x.dep.ns.one.v1~"}, + {"$ref": "gts://gts.x.dep.ns.two.v1~"} + ] + }); + let refs = extract_gts_refs(&schema).unwrap(); + let expected: BTreeSet = ["gts.x.dep.ns.one.v1~", "gts.x.dep.ns.two.v1~"] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!(refs, expected); + } + + #[test] + fn extract_gts_refs_empty_fragment_normalizes_to_id() { + // A trailing empty fragment (`#`) is stripped to the canonical id. + let schema = json!({"$ref": "gts://gts.x.dep.ns.a.v1~#"}); + let refs = extract_gts_refs(&schema).unwrap(); + assert_eq!(refs, BTreeSet::from(["gts.x.dep.ns.a.v1~".to_owned()])); + } + + #[test] + fn extract_gts_refs_internal_pointer_only_excluded() { + // A schema with only internal `#/...` pointers has no external edges. + let schema = json!({ + "type": "object", + "properties": {"a": {"$ref": "#/$defs/Local"}}, + "$defs": {"Local": {"type": "string"}} + }); + assert!(extract_gts_refs(&schema).unwrap().is_empty()); + } +} diff --git a/gts/src/schema_resolver.rs b/gts/src/schema_resolver.rs new file mode 100644 index 0000000..70ea165 --- /dev/null +++ b/gts/src/schema_resolver.rs @@ -0,0 +1,283 @@ +//! `$ref` resolution for registered GTS schemas: inlining `gts://` and local +//! `#/` references into a self-contained body. A `$ref` target is inlined at a +//! non-root position, so its root-only keys (`$id`/`$schema` and the `x-gts-*` +//! type-level modifiers) are stripped on the way in. `allOf` and the other +//! combinators are preserved verbatim — composition is left to the JSON Schema +//! validator, not flattened here. +//! +//! [`SchemaResolver`] depends only on the narrow [`SchemaProvider`] lookup, not +//! on `GtsStore` directly; the store implements `SchemaProvider` and exposes +//! `resolve_schema_refs`/`try_resolve_schema_refs` as thin wrappers. + +use serde_json::Value; + +use crate::gts::GTS_URI_PREFIX; +use crate::store::StoreError; + +/// Read-only schema lookup the resolver needs from its host. +/// +/// Implemented by `GtsStore`; abstracts the resolver away from the store's +/// internals so it can be exercised against any registry-like source. +pub(crate) trait SchemaProvider { + /// The registered schema document for the canonical type id `type_id`, or + /// `None` if no *schema* with that id is registered. Implementations must + /// return `None` for non-schema entities so a `$ref` to one stays + /// unresolved rather than silently inlining a non-schema body. + fn schema_content(&self, type_id: &str) -> Option<&Value>; +} + +/// Inlines `$ref`s in a JSON Schema using a [`SchemaProvider`] for lookups. +pub(crate) struct SchemaResolver<'a> { + provider: &'a dyn SchemaProvider, +} + +impl<'a> SchemaResolver<'a> { + pub(crate) fn new(provider: &'a dyn SchemaProvider) -> Self { + Self { provider } + } + + /// Best-effort `$ref` resolution: resolvable `gts://` `$ref`s are replaced + /// with the referenced schema content; external refs that cannot be + /// resolved are preserved in the returned value rather than removed. Use + /// [`Self::try_resolve`] when unresolved refs must be treated as an error. + pub(crate) fn resolve(&self, schema: &Value) -> Value { + let mut visited = std::collections::HashSet::new(); + let mut cycle_found = false; + let mut unresolved_refs = Vec::new(); + self.resolve_inner(schema, &mut visited, &mut cycle_found, &mut unresolved_refs) + } + + /// Like [`Self::resolve`] but returns an error if any external `$ref` + /// cannot be resolved or a circular `$ref` is detected. + /// + /// Uses DFS-path cycle detection: a `$ref` target is held in the seen-set + /// only while its resolution is in progress on the current DFS stack and + /// removed once that subtree finishes. Re-entry into an in-progress target + /// is a true cycle. Multiple independent occurrences of the same `$ref` + /// (e.g. duplicate refs in `allOf`) are NOT flagged — redundant manual + /// aggregation across an `$id` chain is allowed. + /// + /// # Errors + /// Returns [`StoreError::UnresolvedRefs`] if any external `$ref` cannot be + /// resolved, or [`StoreError::CircularRef`] if a circular `$ref` is + /// detected. + pub(crate) fn try_resolve(&self, schema: &Value) -> Result { + let mut visited = std::collections::HashSet::new(); + let mut cycle_found = false; + let mut unresolved_refs = Vec::new(); + let resolved = + self.resolve_inner(schema, &mut visited, &mut cycle_found, &mut unresolved_refs); + if cycle_found { + Err(StoreError::CircularRef) + } else if !unresolved_refs.is_empty() { + Err(StoreError::UnresolvedRefs(unresolved_refs)) + } else { + Ok(resolved) + } + } + + #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] + fn resolve_inner( + &self, + schema: &Value, + visited: &mut std::collections::HashSet, + cycle_found: &mut bool, + unresolved_refs: &mut Vec, + ) -> Value { + // Recursively resolve $ref references in the schema + match schema { + Value::Object(map) => { + if let Some(Value::String(ref_uri)) = map.get("$ref") { + // Handle internal JSON Schema references like #/$defs/GtsInstanceId + // These should be inlined to match schemars 0.8 behavior (is_referenceable=false) + match ref_uri.as_str() { + "#/$defs/GtsInstanceId" => { + return crate::GtsInstanceId::json_schema_value(); + } + "#/$defs/GtsTypeId" | "#/$defs/GtsSchemaId" => { + return crate::GtsTypeId::json_schema_value(); + } + s if s.starts_with("#/") => { + // Other internal references - keep as-is + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert( + k.clone(), + self.resolve_inner(v, visited, cycle_found, unresolved_refs), + ); + } + return Value::Object(new_map); + } + _ => {} // Fall through to external ref handling + } + + // Normalize the ref: strip gts:// prefix to get canonical GTS ID + let canonical_ref = ref_uri.strip_prefix(GTS_URI_PREFIX).unwrap_or(ref_uri); + let (lookup_ref, pointer_fragment) = + if let Some((id, fragment)) = canonical_ref.split_once('#') { + let pointer = if fragment.is_empty() { + Some("") + } else if fragment.starts_with('/') { + Some(fragment) + } else { + None + }; + (id, pointer) + } else { + (canonical_ref, None) + }; + + // Cycle detection: skip if we've already visited this ref + if visited.contains(canonical_ref) { + // Circular $ref detected. Keep this `$ref` in lenient + // output to avoid weakening the schema while preventing + // infinite recursion. + *cycle_found = true; + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert( + k.clone(), + if k == "$ref" { + v.clone() + } else { + self.resolve_inner(v, visited, cycle_found, unresolved_refs) + }, + ); + } + return Value::Object(new_map); + } + + // Try to resolve the reference using the canonical ID + if let Some(content) = self.provider.schema_content(lookup_ref) { + let target_content = match pointer_fragment { + Some("") => Some(content), + Some(pointer) => content.pointer(pointer), + None if canonical_ref.contains('#') => None, + None => Some(content), + }; + + if let Some(target_content) = target_content { + // Mark as visited before recursing + visited.insert(canonical_ref.to_owned()); + // Recursively resolve refs in the referenced schema + let mut resolved = self.resolve_inner( + target_content, + visited, + cycle_found, + unresolved_refs, + ); + visited.remove(canonical_ref); + + // The target is inlined at a non-root position (e.g. an + // `allOf` branch), so drop keys that are only meaningful at + // a type root: `$id`/`$schema` (URL/dialect resolution) and + // the type-level modifiers (they describe the referenced + // type, not the host; trait composition lives in + // `effective_traits`/`effective_traits_schema`). Everything + // else is preserved verbatim. + if let Value::Object(ref mut resolved_map) = resolved { + resolved_map.remove("$id"); + resolved_map.remove("$schema"); + resolved_map.remove(crate::schema_modifiers::X_GTS_FINAL); + resolved_map.remove(crate::schema_modifiers::X_GTS_ABSTRACT); + resolved_map.remove(crate::schema_traits::X_GTS_TRAITS); + resolved_map.remove(crate::schema_traits::X_GTS_TRAITS_SCHEMA); + } + + // If the original object has only $ref, return the resolved schema + if map.len() == 1 { + return resolved; + } + + // Otherwise combine the resolved schema with the + // siblings via `allOf`. A last-wins merge would let a + // sibling drop or loosen the target's constraints + // (e.g. `required`, `additionalProperties`). + match resolved { + Value::Object(resolved_map) => { + let mut siblings = serde_json::Map::new(); + for (k, v) in map { + if k != "$ref" { + siblings.insert( + k.clone(), + self.resolve_inner( + v, + visited, + cycle_found, + unresolved_refs, + ), + ); + } + } + if siblings.is_empty() { + return Value::Object(resolved_map); + } + let mut merged = serde_json::Map::new(); + merged.insert( + "allOf".to_owned(), + Value::Array(vec![ + Value::Object(resolved_map), + Value::Object(siblings), + ]), + ); + return Value::Object(merged); + } + // Non-object target (e.g. a boolean schema via + // a pointer fragment) with siblings: `$ref` + // wins per JSON Schema precedence. + other => return other, + } + } + } + if !ref_uri.starts_with('#') { + unresolved_refs.push(ref_uri.clone()); + } + + // If we can't resolve, keep the $ref in lenient output. Dropping + // it would silently weaken the schema, especially when only + // annotation siblings such as `description` remain. + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert( + k.clone(), + if k == "$ref" { + v.clone() + } else { + self.resolve_inner(v, visited, cycle_found, unresolved_refs) + }, + ); + } + return Value::Object(new_map); + } + + // `allOf` (and every other keyword) is handled by the generic + // recursion below: each branch is resolved in place, preserving + // the composition verbatim. We deliberately do NOT flatten + // branches into one object — that dropped non-property keywords + // and collapsed same-named properties to last-wins instead of + // intersection. Trait composition lives in `effective_traits`/ + // `effective_traits_schema`, not in this resolved body. + + // Recursively process all properties + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + new_map.insert( + k.clone(), + self.resolve_inner(v, visited, cycle_found, unresolved_refs), + ); + } + Value::Object(new_map) + } + Value::Array(arr) => Value::Array( + arr.iter() + .map(|v| self.resolve_inner(v, visited, cycle_found, unresolved_refs)) + .collect(), + ), + _ => schema.clone(), + } + } +} + +#[cfg(test)] +#[path = "schema_resolver_test.rs"] +mod schema_resolver_test; diff --git a/gts/src/schema_resolver_test.rs b/gts/src/schema_resolver_test.rs new file mode 100644 index 0000000..266030d --- /dev/null +++ b/gts/src/schema_resolver_test.rs @@ -0,0 +1,447 @@ +//! Unit tests for [`SchemaResolver`]. They drive the resolver directly against +//! a tiny in-memory [`SchemaProvider`] mock (`MapProvider`) — no `GtsStore` +//! involved — so they exercise `SchemaResolver::resolve` / `try_resolve` in +//! isolation. End-to-end coverage of the `GtsStore` wrappers and provider +//! lookup semantics lives in `store_test.rs`. + +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use super::{SchemaProvider, SchemaResolver}; +use crate::store::StoreError; + +/// Minimal [`SchemaProvider`]: a map from canonical type id to schema document. +#[derive(Default)] +struct MapProvider { + schemas: HashMap, +} + +impl MapProvider { + fn new() -> Self { + Self::default() + } + + fn with(mut self, type_id: &str, content: Value) -> Self { + self.schemas.insert(type_id.to_owned(), content); + self + } +} + +impl SchemaProvider for MapProvider { + fn schema_content(&self, type_id: &str) -> Option<&Value> { + self.schemas.get(type_id) + } +} + +// By-value `json!(...)` literals read cleaner at the call sites. +#[allow(clippy::needless_pass_by_value)] +fn resolve(provider: &MapProvider, schema: Value) -> Value { + SchemaResolver::new(provider).resolve(&schema) +} + +#[allow(clippy::needless_pass_by_value)] +fn try_resolve(provider: &MapProvider, schema: Value) -> Result { + SchemaResolver::new(provider).try_resolve(&schema) +} + +#[test] +fn test_resolve_passthrough_values_without_refs() { + let p = MapProvider::new(); + for v in [ + json!({}), + Value::Null, + json!([1, 2, 3]), + json!("test"), + json!({"outer": {"inner": {"deep": "value"}}}), + ] { + assert_eq!(resolve(&p, v.clone()), v); + } +} + +#[test] +fn test_resolve_inlines_exact_ref() { + let p = MapProvider::new().with( + "gts.x.core.events.type.v1~", + json!({ + "$id": "gts://gts.x.core.events.type.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}, "event": {"type": "string"}}, + "required": ["id"] + }), + ); + + let resolved = resolve(&p, json!({"$ref": "gts://gts.x.core.events.type.v1~"})); + + assert_eq!( + resolved, + json!({ + "type": "object", + "properties": {"id": {"type": "string"}, "event": {"type": "string"}}, + "required": ["id"] + }) + ); +} + +#[test] +fn test_resolve_inlines_nested_ref() { + let p = MapProvider::new().with( + "gts.x.core.events.detail.v1~", + json!({ + "$id": "gts://gts.x.core.events.detail.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"code": {"type": "string"}} + }), + ); + + let resolved = resolve( + &p, + json!({ + "type": "object", + "properties": {"detail": {"$ref": "gts://gts.x.core.events.detail.v1~"}} + }), + ); + + assert_eq!( + resolved["properties"]["detail"], + json!({"type": "object", "properties": {"code": {"type": "string"}}}) + ); +} + +#[test] +fn test_resolve_allof_inlines_refs_and_preserves_structure() { + // `allOf` is preserved verbatim: every branch is resolved in place ($ref + // inlined, $id/$schema stripped), branches are NOT flattened/merged into a + // single object. Regression guard for faithful inlining. + let p = MapProvider::new().with( + "gts.x.core.events.base.v1~", + json!({ + "$id": "gts://gts.x.core.events.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + "additionalProperties": false + }), + ); + + let resolved = resolve( + &p, + json!({ + "type": "object", + "allOf": [ + {"$ref": "gts://gts.x.core.events.base.v1~"}, + {"type": "object", "properties": {"extra": {"type": "string"}}, "minProperties": 1} + ] + }), + ); + + assert_eq!( + resolved, + json!({ + "type": "object", + "allOf": [ + { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + "additionalProperties": false + }, + {"type": "object", "properties": {"extra": {"type": "string"}}, "minProperties": 1} + ] + }) + ); +} + +#[test] +fn test_resolve_anyof_inlines_refs() { + let p = MapProvider::new().with( + "gts.x.core.events.a.v1~", + json!({ + "$id": "gts://gts.x.core.events.a.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"a": {"type": "string"}} + }), + ); + + let resolved = resolve( + &p, + json!({"anyOf": [{"$ref": "gts://gts.x.core.events.a.v1~"}, {"type": "null"}]}), + ); + + assert_eq!( + resolved, + json!({ + "anyOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "null"} + ] + }) + ); +} + +#[test] +fn test_resolve_oneof_inlines_refs() { + let p = MapProvider::new() + .with( + "gts.x.core.events.x.v1~", + json!({ + "$id": "gts://gts.x.core.events.x.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"x": {"type": "integer"}} + }), + ) + .with( + "gts.x.core.events.y.v1~", + json!({ + "$id": "gts://gts.x.core.events.y.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"y": {"type": "boolean"}} + }), + ); + + let resolved = resolve( + &p, + json!({ + "oneOf": [ + {"$ref": "gts://gts.x.core.events.x.v1~"}, + {"$ref": "gts://gts.x.core.events.y.v1~"} + ] + }), + ); + + assert_eq!( + resolved, + json!({ + "oneOf": [ + {"type": "object", "properties": {"x": {"type": "integer"}}}, + {"type": "object", "properties": {"y": {"type": "boolean"}}} + ] + }) + ); +} + +#[test] +fn test_resolve_strips_type_level_modifiers_from_inlined_ref() { + // When a base is inlined into a non-root position (here an `allOf` branch), + // the root-only keys are dropped: `$id`, `$schema`, and the type-level + // modifiers `x-gts-final`/`x-gts-abstract`/`x-gts-traits`/ + // `x-gts-traits-schema`. Everything else (incl. `title`) is preserved. + let p = MapProvider::new().with( + "gts.x.core.events.modbase.v1~", + json!({ + "$id": "gts://gts.x.core.events.modbase.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Mod Base", + "x-gts-final": true, + "x-gts-abstract": true, + "x-gts-traits": {"tier": "gold"}, + "x-gts-traits-schema": {"type": "object", "properties": {"tier": {"type": "string"}}}, + "properties": {"id": {"type": "string"}}, + "required": ["id"] + }), + ); + + let resolved = resolve( + &p, + json!({"allOf": [{"$ref": "gts://gts.x.core.events.modbase.v1~"}]}), + ); + + assert_eq!( + resolved, + json!({ + "allOf": [ + { + "type": "object", + "title": "Mod Base", + "properties": {"id": {"type": "string"}}, + "required": ["id"] + } + ] + }) + ); +} + +#[test] +fn test_resolve_ref_with_object_siblings_composes_into_allof() { + // A `$ref` carrying object-shaped sibling keywords composes the resolved + // target with the siblings via `allOf` (rather than a lossy last-wins merge). + let p = MapProvider::new().with( + "gts.x.core.events.base.v1~", + json!({ + "$id": "gts://gts.x.core.events.base.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}} + }), + ); + + let resolved = resolve( + &p, + json!({ + "$ref": "gts://gts.x.core.events.base.v1~", + "properties": {"name": {"type": "string"}} + }), + ); + + assert_eq!( + resolved, + json!({ + "allOf": [ + {"type": "object", "properties": {"id": {"type": "string"}}}, + {"properties": {"name": {"type": "string"}}} + ] + }) + ); +} + +#[test] +fn test_resolve_keeps_unresolved_bare_ref() { + let p = MapProvider::new(); + let schema = json!({"$ref": "gts://gts.x.core.events.missing.v1~"}); + assert_eq!(resolve(&p, schema.clone()), schema); +} + +#[test] +fn test_resolve_keeps_unresolved_ref_with_siblings() { + let p = MapProvider::new(); + let schema = json!({ + "type": "object", + "properties": { + "event": { + "$ref": "gts://gts.x.core.events.missing.v1~", + "description": "missing dependency must not be dropped" + } + } + }); + let resolved = resolve(&p, schema.clone()); + assert_eq!(resolved, schema); + assert_eq!( + resolved["properties"]["event"]["$ref"], + "gts://gts.x.core.events.missing.v1~" + ); +} + +#[test] +fn test_try_resolve_errors_on_unresolved_ref() { + let p = MapProvider::new(); + let err = try_resolve( + &p, + json!({ + "type": "object", + "properties": { + "event": { + "$ref": "gts://gts.x.core.events.missing.v1~", + "description": "strict mode should reject this" + } + } + }), + ) + .expect_err("missing external ref should fail checked resolution"); + + assert!(matches!( + &err, + StoreError::UnresolvedRefs(refs) + if refs == &["gts://gts.x.core.events.missing.v1~".to_owned()] + )); +} + +#[test] +fn test_try_resolve_allows_duplicate_ref_in_allof() { + // Redundant manual aggregation (the same $ref appearing more than once in an + // allOf composition) uses DFS-path cycle detection, so independent duplicate + // $refs are not flagged as cycles. + let p = MapProvider::new().with( + "gts.x.test.dup.trait.v1~", + json!({ + "$id": "gts://gts.x.test.dup.trait.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"retention": {"type": "string"}} + }), + ); + + let schema = json!({ + "type": "object", + "allOf": [ + {"$ref": "gts://gts.x.test.dup.trait.v1~"}, + {"$ref": "gts://gts.x.test.dup.trait.v1~"} + ] + }); + + assert!(try_resolve(&p, schema.clone()).is_ok()); + assert!(resolve(&p, schema).is_object()); +} + +#[test] +fn test_resolve_pointer_to_boolean_with_siblings() { + // A gts:// $ref with a pointer fragment that resolves to a non-object + // (boolean) subschema, plus a sibling keyword. The ref resolves, so it must + // NOT be reported unresolved, and the resolved boolean wins per $ref precedence. + let p = MapProvider::new().with( + "gts.x.core.events.flag.v1~", + json!({ + "$id": "gts://gts.x.core.events.flag.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "$defs": {"closed": false} + }), + ); + + let resolved = try_resolve( + &p, + json!({ + "$ref": "gts://gts.x.core.events.flag.v1~#/$defs/closed", + "description": "extra" + }), + ) + .expect("resolved non-object ref with siblings must not be reported unresolved"); + + assert_eq!(resolved, json!(false)); +} + +#[test] +fn test_resolve_circular_ref_does_not_hang() { + // A refs B, B refs A. Lenient resolve must terminate; checked resolution + // reports the cycle. + let p = MapProvider::new() + .with( + "gts.x.test.circ.a.v1~", + json!({ + "$id": "gts://gts.x.test.circ.a.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [{"$ref": "gts://gts.x.test.circ.b.v1~"}], + "properties": {"id": {"type": "string"}} + }), + ) + .with( + "gts.x.test.circ.b.v1~", + json!({ + "$id": "gts://gts.x.test.circ.b.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [{"$ref": "gts://gts.x.test.circ.a.v1~"}], + "properties": {"name": {"type": "string"}} + }), + ); + + let schema = json!({ + "$id": "gts://gts.x.test.circ.a.v1~", + "type": "object", + "allOf": [{"$ref": "gts://gts.x.test.circ.b.v1~"}], + "properties": {"id": {"type": "string"}} + }); + + assert!(resolve(&p, schema.clone()).is_object()); + assert!(matches!( + try_resolve(&p, schema).expect_err("circular ref must fail checked resolution"), + StoreError::CircularRef + )); +} diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index 7bf08d4..ae933db 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -10,19 +10,20 @@ //! via `allOf` into the *effective trait schema*. //! - `x-gts-traits` objects → merge per RFC 7396 JSON Merge Patch into the //! *effective traits object*. -//! 3. Apply defaults from the effective trait schema to fill unresolved trait -//! properties (materialization step). +//! 3. Materialize unresolved trait properties from the effective trait schema, +//! filling each absent property with its `const` (a locked value) or, failing +//! that, its `default`. //! 4. Validate the effective traits object against the effective trait schema //! (completeness check runs only when the type is non-abstract — see -//! `store.rs::validate_schema_traits`). +//! `store.rs::validate_schema`). //! //! **Override semantics (RFC 7396 JSON Merge Patch):** //! - Scalars: descendant value wins (last-wins). //! - Objects: deep-merge recursively (keys not restated by the descendant are //! preserved from the ancestor). //! - Arrays: replace wholesale (no element-wise merge). -//! - `null` at any depth deletes the key, after which `apply_defaults` may -//! re-substitute a default. +//! - `null` at any depth deletes the key, after which `materialize_traits` may +//! re-substitute a `const` or `default`. //! - Locking publisher-controlled values is done via JSON Schema `const` in //! `x-gts-traits-schema`; the registry carries no GTS-specific immutability //! rule. @@ -56,6 +57,73 @@ pub const X_GTS_TRAITS: &str = "x-gts-traits"; /// Prevents stack overflow on deeply nested or maliciously crafted schemas. const MAX_RECURSION_DEPTH: usize = 64; +/// Built trait validation artifacts plus the raw chain inputs they were built +/// from. A single self-contained value: callers build it once (via +/// [`build_effective_traits`] or `GtsStore::effective_traits`) and then both +/// read the composed `schema`/`values` and run [`EffectiveTraits::validate`] +/// off the same instance — no rebuild, no separate bookkeeping flags. +pub(crate) struct EffectiveTraits { + /// Dialect-pinned, `allOf`-composed effective trait schema. + pub schema: Value, + /// Chain-merged (RFC 7396) and const/default-materialized trait values. + pub values: Value, + /// `$ref`-resolved `x-gts-traits-schema` subschemas, root → leaf — retained + /// for per-index integrity checks and the closed-entity check. + pub(crate) resolved_trait_schemas: Vec, + /// RFC 7396-merged `x-gts-traits` values across the chain (pre-defaults). + pub(crate) merged_traits: Value, +} + +impl EffectiveTraits { + /// `true` when the chain contributed at least one `x-gts-traits-schema`. + fn has_schema(&self) -> bool { + !self.resolved_trait_schemas.is_empty() + } + + /// `true` when the chain supplied at least one explicit `x-gts-traits` value. + fn has_explicit_values(&self) -> bool { + self.merged_traits + .as_object() + .is_some_and(|m| !m.is_empty()) + } + + /// Validate these built artifacts. Runs, in order: per-index integrity of + /// the collected subschemas (preserving indexed error messages), the + /// empty-chain and `false`-schema guards, then JSON Schema + `x-gts-ref` + /// value validation (with the required-trait completeness check when + /// `check_unresolved`). + /// + /// # Errors + /// Returns `Vec` of error messages if any trait schema is malformed, + /// if traits are provided without a schema, if the schema resolves to + /// `false` with values present, or if values don't conform. + pub(crate) fn validate(&self, check_unresolved: bool) -> Result<(), Vec> { + validate_trait_schema_integrity(&self.resolved_trait_schemas)?; + + if !self.has_schema() { + if self.has_explicit_values() { + return Err(vec![format!( + "{X_GTS_TRAITS} values provided but no {X_GTS_TRAITS_SCHEMA} is defined in the \ + inheritance chain" + )]); + } + return Ok(()); + } + + if effective_schema_is_false(&self.schema) { + if self.has_explicit_values() { + return Err(vec![format!( + "{X_GTS_TRAITS_SCHEMA} resolves to `false` in the chain — \ + {X_GTS_TRAITS} values are prohibited" + )]); + } + return Ok(()); + } + + validate_trait_values(&self.schema, &self.values, check_unresolved) + } +} + // --------------------------------------------------------------------------- // Inline trait-schema construction // --------------------------------------------------------------------------- @@ -147,8 +215,8 @@ pub fn inline_traits_schema_of() -> Value { /// (not allOf-flattened) so that `x-gts-*` extension keys are preserved. /// /// This is the self-contained entry point used by unit tests. The store -/// integration uses [`validate_effective_traits`] directly after collecting -/// and resolving trait schemas itself. +/// integration builds an [`EffectiveTraits`] from its own chain walk and calls +/// [`EffectiveTraits::validate`] on it. /// /// # Errors /// Returns `Vec` of error messages if trait values don't conform to the @@ -167,54 +235,17 @@ pub fn validate_traits_chain(chain_schemas: &[(String, Value)]) -> Result<(), Ve let dialect = chain_schemas .last() .and_then(|(_, content)| content.get("$schema").and_then(Value::as_str)); - validate_effective_traits(&trait_schemas, &Value::Object(merged), true, dialect) + build_effective_traits(&trait_schemas, &Value::Object(merged), dialect).validate(true) } -/// Validates trait values against the effective trait schema built from the -/// given list of resolved trait schemas. -/// -/// `resolved_trait_schemas` – `x-gts-traits-schema` values collected from the -/// chain, with any `$ref` inside them already resolved. -/// -/// `merged_traits` – shallow-merged `x-gts-traits` values (rightmost wins). -/// -/// When `check_unresolved` is `true`, every *required* trait-schema property -/// without a default must have a value in `merged_traits` (optional properties -/// may be left unresolved, per README §9.7.5 / OP#13); set to `false` for -/// intermediate schema validation where descendants may still supply values. -/// -/// `dialect` is the host document's `$schema` URI (e.g. -/// `http://json-schema.org/draft-07/schema#`). When `Some`, it pins the JSON -/// Schema draft used to validate trait values so they are interpreted under the -/// same dialect as the rest of the type schema — necessary because the inline -/// trait fragment had its root-only `$schema` stripped when -/// embedded. When `None`, validation falls back to the validator's automatic -/// draft detection (Draft 2020-12 when no `$schema` is present), matching how -/// instance and schema validation behave elsewhere in this crate. A GTS Type -/// Schema always declares `$schema`, so the store path always passes `Some`. +/// Validate each collected `x-gts-traits-schema` subschema before composition, +/// preserving indexed error messages for malformed inputs. Run first by +/// [`EffectiveTraits::validate`]. /// /// # Errors -/// Returns `Vec` of error messages if trait values don't conform to the -/// effective trait schema, if required traits are missing, or if traits exist -/// without a trait schema in the chain. -pub fn validate_effective_traits( - resolved_trait_schemas: &[Value], - merged_traits: &Value, - check_unresolved: bool, - dialect: Option<&str>, -) -> Result<(), Vec> { - let has_trait_values = merged_traits.as_object().is_some_and(|m| !m.is_empty()); - - if resolved_trait_schemas.is_empty() { - if has_trait_values { - return Err(vec![format!( - "{X_GTS_TRAITS} values provided but no {X_GTS_TRAITS_SCHEMA} is defined in the \ - inheritance chain" - )]); - } - return Ok(()); - } - +/// Returns `Vec` of error messages if any collected trait schema is not +/// a JSON Schema object or boolean subschema. +fn validate_trait_schema_integrity(resolved_trait_schemas: &[Value]) -> Result<(), Vec> { // Each x-gts-traits-schema is a JSON Schema subschema. Accepted forms are // an object subschema, `true`, or `false`. Validate JSON Schema integrity // only for object-form subschemas; the boolean forms have well-defined @@ -247,54 +278,66 @@ pub fn validate_effective_traits( } } } + Ok(()) +} - let mut effective_trait_schema = build_effective_trait_schema(resolved_trait_schemas); - - // If any subschema in the chain is the boolean `false`, the effective - // schema is unsatisfiable. A type that carries no traits at all is still - // valid (`false` prohibits traits, not the existence of typed descendants). - // A type carrying any traits fails. - if effective_schema_is_false(&effective_trait_schema) { - if has_trait_values { - return Err(vec![format!( - "{X_GTS_TRAITS_SCHEMA} resolves to `false` in the chain — \ - {X_GTS_TRAITS} values are prohibited" - )]); - } - return Ok(()); - } +/// Build the dialect-pinned effective traits schema and the materialized +/// effective-traits object from chain-collected inputs, returning a +/// self-contained [`EffectiveTraits`] (the inputs are retained so the result +/// can validate itself via [`EffectiveTraits::validate`] without a rebuild). +/// +/// `resolved_trait_schemas` must already have any `$ref`s resolved. `dialect` +/// is the host document's `$schema`, re-injected here because the inline trait +/// fragment had its root-only `$schema` stripped when embedded; when `None`, +/// the schema is left as-is so the validator detects/defaults the draft (Draft +/// 2020-12), matching instance/schema validation elsewhere in this crate. +pub(crate) fn build_effective_traits( + resolved_trait_schemas: &[Value], + merged_traits: &Value, + dialect: Option<&str>, +) -> EffectiveTraits { + let mut effective_traits_schema = build_effective_traits_schema(resolved_trait_schemas); - // Pin the JSON Schema dialect to the host document's `$schema` so trait - // values validate under the same draft as the rest of the type schema - // (the dialect is set by `$schema`). The inline trait fragment had its - // root-only `$schema` stripped when embedded, so we (re)set it from - // the host here. When the caller supplies no dialect, we leave the schema as - // is and let the validator detect/default the draft (Draft 2020-12), matching - // instance/schema validation elsewhere in this crate. if let Some(dialect) = dialect - && let Some(obj) = effective_trait_schema.as_object_mut() + && let Some(obj) = effective_traits_schema.as_object_mut() { obj.insert("$schema".to_owned(), Value::String(dialect.to_owned())); } - let effective_traits = apply_defaults(&effective_trait_schema, merged_traits); + let values = materialize_traits(&effective_traits_schema, merged_traits); + EffectiveTraits { + schema: effective_traits_schema, + values, + resolved_trait_schemas: resolved_trait_schemas.to_vec(), + merged_traits: merged_traits.clone(), + } +} +/// Validate a default-filled trait values object against its composed schema: +/// standard JSON Schema validation (plus the required-trait completeness check +/// when `check_unresolved`) followed by GTS `x-gts-ref` enforcement, which the +/// standard validator ignores as an unknown keyword. +fn validate_trait_values( + effective_traits_schema: &Value, + effective_traits: &Value, + check_unresolved: bool, +) -> Result<(), Vec> { let mut errors = match validate_traits_against_schema( - &effective_trait_schema, - &effective_traits, + effective_traits_schema, + effective_traits, check_unresolved, ) { Ok(()) => Vec::new(), Err(e) => e, }; - // Enforce `x-gts-ref` on trait values. The standard - // `jsonschema` validator ignores `x-gts-ref` as an unknown keyword, so a - // trait value that violates the declared GTS-prefix would otherwise slip - // through. Treat the effective trait-schema as the schema and the - // materialized effective traits as the instance. + // Enforce `x-gts-ref` on trait values. The standard `jsonschema` validator + // ignores `x-gts-ref` as an unknown keyword, so a trait value that violates + // the declared GTS-prefix would otherwise slip through. Treat the effective + // trait-schema as the schema and the materialized effective traits as the + // instance. let xref = crate::x_gts_ref::XGtsRefValidator::new(); - for err in xref.validate_instance(&effective_traits, &effective_trait_schema, "") { + for err in xref.validate_instance(effective_traits, effective_traits_schema, "") { errors.push(format!("trait x-gts-ref: {err}")); } @@ -337,6 +380,73 @@ fn effective_schema_is_false_recursive(schema: &Value, depth: usize) -> bool { // Collection helpers (pub(crate) so the store can call them) // --------------------------------------------------------------------------- +/// Inline JSON Pointer `$ref`s (`#/...`) inside a trait-schema fragment by +/// resolving them against `root` — the host document the fragment was lifted +/// from. +/// +/// The extracted `x-gts-traits-schema` fragment carries no `$defs` of its own, +/// so a root-relative pointer like `#/$defs/Retention` would dangle once the +/// fragment is composed into the effective trait-schema's `allOf` (the document +/// root there no longer holds those `$defs`). Per gts-spec §9.7.5, a `$ref` +/// inside `x-gts-traits-schema` MUST resolve under standard JSON Schema rules, +/// and a JSON Pointer fragment resolves against the host document — which is +/// `root` here. Inlining at collection time, while `root` is still the document +/// root, keeps the composed fragment self-contained. +/// +/// Only pointers that actually resolve against `root` are inlined; anything +/// else (notably `gts://` refs and the synthetic `#/$defs/GtsInstanceId` family +/// that the macro emits and [`crate::store::GtsStore::resolve_schema_refs`] +/// special-cases) is left untouched. Recursion is bounded by +/// [`MAX_RECURSION_DEPTH`]. +pub(crate) fn inline_local_pointers(fragment: &Value, root: &Value) -> Value { + inline_local_pointers_recursive(fragment, root, 0) +} + +fn inline_local_pointers_recursive(value: &Value, root: &Value, depth: usize) -> Value { + if depth >= MAX_RECURSION_DEPTH { + return value.clone(); + } + match value { + Value::Object(map) => { + if let Some(Value::String(r)) = map.get("$ref") + && let Some(ptr) = r.strip_prefix("#/") + && let Some(target) = root.pointer(&format!("/{ptr}")) + { + // Resolve the target against the same root, then overlay any + // sibling keywords (JSON Schema `$ref`-with-siblings). + let mut resolved = inline_local_pointers_recursive(target, root, depth + 1); + if map.len() > 1 + && let Value::Object(resolved_map) = &mut resolved + { + for (k, v) in map { + if k != "$ref" { + resolved_map.insert( + k.clone(), + inline_local_pointers_recursive(v, root, depth + 1), + ); + } + } + } + return resolved; + } + let mut out = serde_json::Map::with_capacity(map.len()); + for (k, v) in map { + out.insert( + k.clone(), + inline_local_pointers_recursive(v, root, depth + 1), + ); + } + Value::Object(out) + } + Value::Array(arr) => Value::Array( + arr.iter() + .map(|v| inline_local_pointers_recursive(v, root, depth + 1)) + .collect(), + ), + _ => value.clone(), + } +} + /// Recursively search a schema value for `x-gts-traits-schema` entries. /// /// Handles both top-level and `allOf`-nested occurrences. @@ -371,7 +481,7 @@ fn collect_trait_schema_recursive(value: &Value, out: &mut Vec, depth: us /// /// `null` values are preserved verbatim — they carry RFC 7396 "delete this /// key" semantics and must reach the cross-level merge step (in -/// `store::validate_schema_traits`) intact. Within a single level, multiple +/// `store::effective_traits`) intact. Within a single level, multiple /// `x-gts-traits` blocks (e.g. one inline + ones nested in `allOf` overlays) /// are unioned with later-occurring entries winning per key. The cross-level /// step then applies these per-level patches in chain order via RFC 7396. @@ -417,7 +527,8 @@ fn collect_traits_recursive( /// - Objects merge recursively (keys not restated by `patch` are preserved). /// - `null` values **delete** the corresponding key from `target`; if the /// target had no such key the null is a no-op (the key remains absent so -/// `apply_defaults` can later substitute a `default` from the trait schema). +/// `materialize_traits` can later substitute a `const`/`default` from the +/// trait schema). /// /// This is the trait-merge primitive used to compose `x-gts-traits` along the /// `$id` chain (root → leaf). @@ -474,7 +585,7 @@ fn merge_rfc7396_recursive( /// specification — authors should use `additionalProperties: false` only in the /// outermost (single) trait schema, or omit it in favour of explicit property /// lists. -fn build_effective_trait_schema(schemas: &[Value]) -> Value { +pub(crate) fn build_effective_traits_schema(schemas: &[Value]) -> Value { match schemas.len() { 0 => Value::Object(serde_json::Map::new()), 1 => schemas[0].clone(), @@ -487,17 +598,29 @@ fn build_effective_trait_schema(schemas: &[Value]) -> Value { } } -/// Apply JSON Schema `default` values from the effective trait schema to the -/// merged traits object for any properties that are not yet present. +/// Materialize trait values from the effective trait schema onto the merged +/// traits object, filling any property that is not yet present. +/// +/// Resolution precedence for an absent property is **`const` → `default`**: a +/// `const` locks the value (it is the only value the schema accepts, so the +/// effective value is fully determined even when the chain never restates it), +/// and `default` fills the rest. A property already supplied by the chain is +/// left as-is — a value that conflicts with a `const`/enum is caught by the +/// later JSON Schema validation, which gives a clearer error than silently +/// overwriting it here. /// -/// Handles nested object properties recursively: if a trait property is an object -/// type with its own `properties` and `default` values, those are applied to the -/// corresponding nested object in the traits. -fn apply_defaults(trait_schema: &Value, traits: &Value) -> Value { - apply_defaults_recursive(trait_schema, traits, 0) +/// Handles nested object properties recursively: if a present trait property is +/// an object type with its own `properties`, nested `const`/`default` values +/// are materialized into the corresponding nested object. +fn materialize_traits(trait_schema: &Value, traits: &Value) -> Value { + materialize_traits_recursive(trait_schema, traits, 0) } -fn apply_defaults_recursive(trait_schema: &Value, traits: &Value, depth: usize) -> Value { +/// Per-property materialization view: (most-derived declaration, nearest +/// `const`, nearest `default`). +type PropResolution = (Value, Option, Option); + +fn materialize_traits_recursive(trait_schema: &Value, traits: &Value, depth: usize) -> Value { if depth >= MAX_RECURSION_DEPTH { return traits.clone(); } @@ -507,32 +630,65 @@ fn apply_defaults_recursive(trait_schema: &Value, traits: &Value, depth: usize) _ => serde_json::Map::new(), }; - // Collect properties from the trait schema (may be in top-level or allOf) - let props = collect_all_properties(trait_schema); + // All property declarations along the chain, in root→leaf order. The same + // property may appear in several `allOf` branches — an ancestor declaring it + // plus a descendant narrowing it. + let mut all_props: Vec<(String, Value)> = Vec::new(); + collect_props_recursive(trait_schema, &mut all_props, 0); + + // Resolve each property once. `const`/`default` are taken from the *nearest* + // (most-derived) declaration that carries them — scanning leaf→root — because + // `default` does not participate in narrowing, so an ancestor default ripples + // to descendants even when a descendant redeclares the property without one + // (gts-spec §9.7.2, ADR-0003). The most-derived declaration also drives the + // "recurse into nested object" decision. + let mut order: Vec = Vec::new(); + let mut resolved: std::collections::HashMap = + std::collections::HashMap::new(); + for (name, sch) in all_props.iter().rev() { + let obj = sch.as_object(); + let entry = resolved.entry(name.clone()).or_insert_with(|| { + order.push(name.clone()); + (sch.clone(), None, None) + }); + if entry.1.is_none() + && let Some(const_val) = obj.and_then(|o| o.get("const")) + { + entry.1 = Some(const_val.clone()); + } + if entry.2.is_none() + && let Some(default_val) = obj.and_then(|o| o.get("default")) + { + entry.2 = Some(default_val.clone()); + } + } - for (prop_name, prop_schema) in &props { - if let Some(prop_obj) = prop_schema.as_object() { - if !result.contains_key(prop_name.as_str()) { - // Property is absent — apply top-level default if present - if let Some(default_val) = prop_obj.get("default") { - result.insert(prop_name.clone(), default_val.clone()); - } - } else if prop_obj.get("type") == Some(&Value::String("object".to_owned())) - && prop_obj.contains_key("properties") - { - // Property is present and is an object type with sub-properties — - // recurse to apply nested defaults. If the input value is a - // non-object (e.g. a string where the schema expects an object), - // the recursion will produce a defaulted object that replaces the - // original value; JSON Schema validation will catch the type - // mismatch later, so this is intentional. - let nested = apply_defaults_recursive( - prop_schema, - result.get(prop_name.as_str()).unwrap_or(&Value::Null), - depth + 1, - ); - result.insert(prop_name.clone(), nested); + for name in &order { + let (prop_schema, nearest_const, nearest_default) = &resolved[name]; + if !result.contains_key(name.as_str()) { + // Property is absent — a `const` locks the value (highest priority), + // otherwise fall back to the nearest `default` up the chain. + if let Some(const_val) = nearest_const { + result.insert(name.clone(), const_val.clone()); + } else if let Some(default_val) = nearest_default { + result.insert(name.clone(), default_val.clone()); } + } else if result.get(name.as_str()).is_some_and(Value::is_object) + && prop_schema.as_object().is_some_and(|o| { + o.get("type") == Some(&Value::String("object".to_owned())) + && o.contains_key("properties") + }) + { + // Present value is an object and the schema declares an object with + // sub-properties — recurse to materialize nested const/default. We + // skip recursion for a non-object value so a freshly materialized + // object doesn't mask the type error from later validation. + let nested = materialize_traits_recursive( + prop_schema, + result.get(name.as_str()).unwrap_or(&Value::Null), + depth + 1, + ); + result.insert(name.clone(), nested); } } @@ -620,6 +776,32 @@ fn collect_required_recursive( } } +/// Return a clone of `schema` with every `required` constraint removed from the +/// root object and from each `allOf` branch — mirroring [`collect_all_required`]'s +/// traversal. Used for abstract-type trait validation, where required-trait +/// completeness is deferred to descendants but the declared types of *provided* +/// trait values must still be enforced. +fn schema_without_required(schema: &Value) -> Value { + let mut out = schema.clone(); + strip_required_recursive(&mut out, 0); + out +} + +fn strip_required_recursive(schema: &mut Value, depth: usize) { + if depth >= MAX_RECURSION_DEPTH { + return; + } + let Some(obj) = schema.as_object_mut() else { + return; + }; + obj.remove("required"); + if let Some(Value::Array(all_of)) = obj.get_mut("allOf") { + for item in all_of { + strip_required_recursive(item, depth + 1); + } + } +} + /// Validate the effective traits object against the effective trait schema. /// /// Uses the `jsonschema` crate for standard JSON Schema validation. This @@ -636,8 +818,22 @@ fn validate_traits_against_schema( ) -> Result<(), Vec> { let mut errors = Vec::new(); + // For abstract types (`check_unresolved == false`) required-trait + // completeness is deferred to descendants, so a missing required trait must + // not fail here. Standard JSON Schema enforces `required` regardless of the + // completeness loop below, so strip `required` from the validation schema in + // that case — the type/enum/etc. constraints on values that ARE present + // still apply. + let stripped; + let validation_schema: &Value = if check_unresolved { + trait_schema + } else { + stripped = schema_without_required(trait_schema); + &stripped + }; + // Standard JSON Schema validation of the traits object - match jsonschema::validator_for(trait_schema) { + match jsonschema::validator_for(validation_schema) { Ok(validator) => { for error in validator.iter_errors(effective_traits) { errors.push(format!("trait validation: {error}")); @@ -679,11 +875,14 @@ fn validate_traits_against_schema( let has_value = traits_obj.is_some_and(|m| m.contains_key(prop_name.as_str())); - let has_default = prop_schema + // A `const` fully determines the value (materialized by + // `materialize_traits`), so it resolves the property just like a + // `default` does. + let has_default_or_const = prop_schema .as_object() - .is_some_and(|m| m.contains_key("default")); + .is_some_and(|m| m.contains_key("default") || m.contains_key("const")); - if !has_value && !has_default { + if !has_value && !has_default_or_const { let expected_type = prop_schema .as_object() .and_then(|m| m.get("type")) @@ -691,10 +890,10 @@ fn validate_traits_against_schema( .unwrap_or("any"); errors.push(format!( "trait property '{prop_name}' (type: {expected_type}) is not resolved: \ - no value provided and no default defined in the trait schema. \ + no value provided and no default or const defined in the trait schema. \ All traits must be resolved (via a {X_GTS_TRAITS} value in the chain \ - or a `default` in the trait schema) on non-abstract types; otherwise \ - mark the type abstract (x-gts-abstract: true)" + or a `default`/`const` in the trait schema) on non-abstract types; \ + otherwise mark the type abstract (x-gts-abstract: true)" )); } } @@ -716,6 +915,34 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn test_trait_schema_integrity_rejects_non_object_non_boolean() { + // A resolved trait schema that is neither an object subschema nor a + // boolean (here, an array) must hit the dedicated error arm. + let schemas = vec![json!([1, 2])]; + let err = validate_trait_schema_integrity(&schemas).unwrap_err(); + assert!( + err.iter() + .any(|m| m.contains("must be an object subschema or a boolean")), + "expected the non-object/non-boolean arm, got: {err:?}" + ); + } + + #[test] + fn test_inline_local_pointers_ref_with_siblings_overlay() { + // `$ref` with sibling keywords: the pointer target is inlined and the + // sibling (`description`) is overlaid onto the result. + let root = json!({ + "$defs": {"X": {"type": "string", "minLength": 1}} + }); + let fragment = json!({"$ref": "#/$defs/X", "description": "extra"}); + let inlined = inline_local_pointers(&fragment, &root); + assert_eq!(inlined["type"], json!("string")); + assert_eq!(inlined["minLength"], json!(1)); + assert_eq!(inlined["description"], json!("extra")); + assert!(inlined.get("$ref").is_none()); + } + #[test] fn test_no_traits_schema_passes() { let chain = vec![( @@ -818,6 +1045,106 @@ mod tests { assert!(validate_traits_chain(&chain).is_ok()); } + #[test] + fn test_const_only_required_trait_resolves_and_materializes() { + // A required trait whose schema pins a `const` (no default, no explicit + // x-gts-traits value) must (a) be materialized into the effective traits + // and (b) pass completeness — its value is fully determined by the lock. + let schemas = vec![json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "channel": {"type": "string", "const": "audit"} + }, + "required": ["channel"] + })]; + let traits = build_effective_traits( + &schemas, + &json!({}), + Some("http://json-schema.org/draft-07/schema#"), + ); + assert_eq!( + traits.values["channel"], "audit", + "const must be materialized into effective traits values" + ); + assert!( + traits.validate(true).is_ok(), + "a const-locked required trait is fully resolved: {:?}", + traits.validate(true) + ); + } + + #[test] + fn test_const_takes_priority_over_default_in_materialization() { + let schemas = vec![json!({ + "type": "object", + "properties": { + "mode": {"type": "string", "const": "locked", "default": "open"} + } + })]; + let traits = build_effective_traits(&schemas, &json!({}), None); + assert_eq!( + traits.values["mode"], "locked", + "const wins over default when the value is absent" + ); + } + + #[test] + fn test_explicit_value_conflicting_with_const_fails() { + // An explicit x-gts-traits value is kept as-is (not silently overwritten + // by const); JSON Schema validation then reports the conflict. + let chain = vec![( + "base~".to_owned(), + json!({"$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"channel": {"type": "string", "const": "audit"}} + }, + "x-gts-traits": {"channel": "events"} + }), + )]; + let err = validate_traits_chain(&chain).unwrap_err(); + assert!( + !err.is_empty(), + "explicit value conflicting with const must fail validation" + ); + } + + #[test] + fn test_ancestor_default_ripples_when_descendant_redeclares_without_default() { + // base declares `retention` with a default; a descendant narrows the same + // property in its own trait-schema but omits the default. Per gts-spec + // §9.7.2 / ADR-0003 the ancestor default does not participate in narrowing + // and still ripples down — it must be materialized (nearest existing + // default wins), not shadowed by the bare redeclaration. + let schemas = vec![ + json!({ + "type": "object", + "properties": {"retention": {"type": "string", "default": "P30D"}}, + "required": ["retention"] + }), + json!({ + "type": "object", + "properties": {"retention": {"type": "string"}} + }), + ]; + let traits = build_effective_traits( + &schemas, + &json!({}), + Some("http://json-schema.org/draft-07/schema#"), + ); + assert_eq!( + traits.values["retention"], "P30D", + "ancestor default must ripple when a descendant redeclares without a default" + ); + assert!( + traits.validate(true).is_ok(), + "the rippled default resolves the required trait: {:?}", + traits.validate(true) + ); + } + #[test] fn test_missing_required_trait_fails() { let chain = vec![ @@ -1607,8 +1934,8 @@ mod tests { } }); - let effective = build_effective_trait_schema(&[base_ts, mid_ts, leaf_ts]); - let materialized = apply_defaults(&effective, &Value::Object(serde_json::Map::new())); + let effective = build_effective_traits_schema(&[base_ts, mid_ts, leaf_ts]); + let materialized = materialize_traits(&effective, &Value::Object(serde_json::Map::new())); let retention = materialized .as_object() @@ -1645,10 +1972,10 @@ mod tests { } }); - let effective = build_effective_trait_schema(&[base_ts, mid_ts, leaf_ts]); + let effective = build_effective_traits_schema(&[base_ts, mid_ts, leaf_ts]); let mut merged = serde_json::Map::new(); merged.insert("retention".to_owned(), Value::String("P42D".to_owned())); - let materialized = apply_defaults(&effective, &Value::Object(merged)); + let materialized = materialize_traits(&effective, &Value::Object(merged)); let retention = materialized .as_object() @@ -1695,8 +2022,8 @@ mod tests { "null patch should remove the key from merged" ); - let effective = build_effective_trait_schema(&[base_ts, leaf_ts]); - let materialized = apply_defaults(&effective, &Value::Object(merged)); + let effective = build_effective_traits_schema(&[base_ts, leaf_ts]); + let materialized = materialize_traits(&effective, &Value::Object(merged)); let retention = materialized .as_object() @@ -1708,6 +2035,79 @@ mod tests { "after null delete, materialization must use the leaf-most default; got {retention}" ); } + + #[test] + fn test_build_effective_traits_materializes_and_pins_dialect() { + let ts = json!({ + "type": "object", + "properties": { "retention": {"type": "string", "default": "P30D"} } + }); + let merged = json!({}); + let traits = super::build_effective_traits( + std::slice::from_ref(&ts), + &merged, + Some("http://json-schema.org/draft-07/schema#"), + ); + assert_eq!( + traits.schema["$schema"], + "http://json-schema.org/draft-07/schema#" + ); + assert_eq!(traits.values["retention"], "P30D"); // default materialized + } + + #[test] + fn test_validate_trait_values_flags_x_gts_ref_violation() { + let schema = json!({ + "type": "object", + "properties": { + "topicRef": {"type": "string", "x-gts-ref": "gts.x.core.events.topic.v1~"} + } + }); + // A value that does not match the required gts prefix must be reported, + // even though the standard jsonschema validator ignores x-gts-ref. + let values = json!({ "topicRef": "not-a-gts-id" }); + let res = super::validate_trait_values(&schema, &values, false); + assert!( + res.is_err(), + "x-gts-ref violation should be reported: {res:?}" + ); + } + + #[test] + fn test_inline_local_pointers_resolves_against_root() { + let root = json!({ + "$defs": { "Retention": {"type": "string", "enum": ["P30D"]} }, + "x-gts-traits-schema": { + "type": "object", + "properties": { "retention": {"$ref": "#/$defs/Retention"} } + } + }); + let fragment = &root["x-gts-traits-schema"]; + let inlined = super::inline_local_pointers(fragment, &root); + let retention = &inlined["properties"]["retention"]; + assert!( + retention.get("$ref").is_none(), + "local pointer must be inlined: {retention}" + ); + assert_eq!(retention["enum"], json!(["P30D"])); + } + + #[test] + fn test_inline_local_pointers_leaves_unresolvable_and_gts_refs_untouched() { + let root = json!({ "type": "object" }); // no $defs + // A `gts://` ref and an unresolvable `#/...` pointer are both left as-is + // (the former is handled later by try_resolve_schema_refs; the + // latter has no target in `root`). + let fragment = json!({ + "allOf": [ + {"$ref": "gts://gts.x.a.b.v1~"}, + {"$ref": "#/$defs/Missing"} + ] + }); + let inlined = super::inline_local_pointers(&fragment, &root); + assert_eq!(inlined["allOf"][0]["$ref"], "gts://gts.x.a.b.v1~"); + assert_eq!(inlined["allOf"][1]["$ref"], "#/$defs/Missing"); + } } #[cfg(test)] diff --git a/gts/src/store.rs b/gts/src/store.rs index 35112f1..08ed662 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, RwLock}; use thiserror::Error; use crate::entities::GtsEntity; -use crate::gts::{GTS_URI_PREFIX, GtsId, GtsIdPattern}; +use crate::gts::{GTS_URI_PREFIX, GtsId, GtsIdError, GtsIdPattern}; use crate::schema_cast::GtsEntityCastResult; /// Custom retriever for resolving gts:// URI scheme references in JSON Schema validation @@ -68,26 +68,22 @@ impl jsonschema::Retrieve for GtsRetriever { #[derive(Debug, Error)] pub enum StoreError { - #[error("JSON object with GTS ID '{0}' not found in store")] - ObjectNotFound(String), - #[error("JSON schema with GTS ID '{0}' not found in store")] + #[error("GTS instance with ID '{0}' not found in store")] + InstanceNotFound(String), + #[error("GTS type schema with ID '{0}' not found in store")] SchemaNotFound(String), - #[error("JSON entity with GTS ID '{0}' not found in store")] - EntityNotFound(String), - #[error("Can't determine JSON schema ID for instance with GTS ID '{0}'")] - SchemaForInstanceNotFound(String), - #[error( - "Cannot cast from schema ID '{0}'. The from_id must be an instance (not ending with '~')" - )] - CastFromSchemaNotAllowed(String), - #[error("Entity must have a valid gts_id")] - InvalidEntity, - #[error("Schema type_id must end with '~'")] - InvalidSchemaId, + #[error("Entity is invalid: {0}")] + InvalidEntity(String), + #[error("Invalid GTS type id: {0}")] + InvalidTypeId(GtsIdError), #[error("{0}")] ValidationError(String), #[error("Invalid $ref: {0}")] InvalidRef(String), + #[error("Circular $ref detected")] + CircularRef, + #[error("Unresolved $ref(s): {}", .0.join(", "))] + UnresolvedRefs(Vec), } pub trait GtsReader: Send { @@ -105,22 +101,77 @@ pub struct GtsStoreQueryResult { pub results: Vec, } +/// Fully-resolved, self-contained view of a GTS type. +/// +/// A pure value computed from store contents — the library holds **no cache** +/// of these. Because schemas are append-only by versioned id (a new version is +/// a new `type_id`), a `ResolvedType` is safe for a *consumer* to cache forever +/// keyed by `type_id`. Note this relies on callers honoring id immutability: +/// `register_schema` does not itself reject re-registering an existing id, so a +/// consumer that overwrites ids in place must invalidate its own cache. +#[derive(Debug, Clone)] +pub struct ResolvedType { + /// The type id this resolution is for (the `type_id` passed to + /// [`GtsStore::validate_schema`]). + pub id: crate::GtsTypeId, + /// `true` when the type declares `x-gts-abstract: true` — a template that + /// cannot have direct instances and defers required-trait completeness. + pub is_abstract: bool, + /// `true` when the type declares `x-gts-final: true` — it cannot be extended. + pub is_final: bool, + /// Type body with all `#/` and `gts://` `$ref`s inlined. + pub schema: Value, + /// Chain-merged (RFC 7396) and default-materialized trait values. + pub effective_traits: Value, + /// Dialect-pinned, `allOf`-composed, `$ref`-inlined effective traits schema. + pub effective_traits_schema: Value, +} + pub struct GtsStore { by_id: HashMap, reader: Option>, } +impl Default for GtsStore { + fn default() -> Self { + Self::new() + } +} + +impl crate::schema_resolver::SchemaProvider for GtsStore { + /// Looks the id up in the registered set directly (no reader fallback) and + /// only exposes it when it is a schema entity — a `$ref` to a non-schema id + /// stays unresolved. + fn schema_content(&self, type_id: &str) -> Option<&Value> { + self.by_id + .get(type_id) + .filter(|entity| entity.is_schema) + .map(|entity| &entity.content) + } +} + impl GtsStore { - pub fn new(reader: Option>) -> Self { - let mut store = GtsStore { + /// Empty, reader-free store. Callers populate it explicitly via + /// [`Self::register`] / [`Self::register_schema`]. With no [`GtsReader`], + /// `get` and resolution never fall back to lazy I/O — the store sees + /// exactly what was registered. + #[must_use] + pub fn new() -> Self { + GtsStore { by_id: HashMap::new(), - reader, - }; - - if store.reader.is_some() { - store.populate_from_reader(); + reader: None, } + } + /// Store backed by a [`GtsReader`], eagerly populated from it. `get` falls + /// back to the reader for ids not yet cached. + #[must_use] + pub fn with_reader(reader: Box) -> Self { + let mut store = GtsStore { + by_id: HashMap::new(), + reader: Some(reader), + }; + store.populate_from_reader(); tracing::info!("Populated GtsStore with {} entities", store.by_id.len()); store } @@ -141,7 +192,9 @@ impl GtsStore { /// # Errors /// Returns `StoreError::InvalidEntity` if the entity has no effective ID. pub fn register(&mut self, entity: GtsEntity) -> Result<(), StoreError> { - let id = entity.effective_id().ok_or(StoreError::InvalidEntity)?; + let id = entity + .effective_id() + .ok_or_else(|| StoreError::InvalidEntity("Entity has no effective ID".to_owned()))?; self.by_id.insert(id, entity); Ok(()) } @@ -149,13 +202,15 @@ impl GtsStore { /// Registers a schema in the store. /// /// # Errors - /// Returns `StoreError::InvalidSchemaId` if the `type_id` doesn't end with '~'. + /// Returns `StoreError::InvalidTypeId` if `type_id` is not a valid GTS type id. pub fn register_schema(&mut self, type_id: &str, schema: &Value) -> Result<(), StoreError> { - if !type_id.ends_with('~') { - return Err(StoreError::InvalidSchemaId); + let gts_id = GtsId::try_new(type_id).map_err(StoreError::InvalidTypeId)?; + if !gts_id.is_type() { + return Err(StoreError::InvalidTypeId(GtsIdError::new( + type_id, + "GTS type IDs must end with '~'", + ))); } - - let gts_id = GtsId::try_new(type_id).map_err(|_| StoreError::InvalidSchemaId)?; let entity = GtsEntity::new( None, None, @@ -188,302 +243,79 @@ impl GtsStore { None } + /// Fetches a schema entity by its type id. + /// + /// Validates that `type_id` is a well-formed GTS *type* id and that the + /// stored entity is actually a schema, so callers don't have to repeat the + /// id parse + `is_schema` checks. + /// + /// # Errors + /// Returns `StoreError::SchemaNotFound` if `type_id` is not a valid type id, + /// if no entity exists for it, or if the entity found is not a schema (e.g. + /// an instance happens to be registered under that id). + fn get_schema_entity(&mut self, type_id: &str) -> Result<&GtsEntity, StoreError> { + if let Err(e) = crate::GtsTypeId::try_new(type_id) { + return Err(StoreError::SchemaNotFound(format!("Invalid type id: {e}"))); + } + match self.get(type_id) { + Some(entity) if entity.is_schema => Ok(entity), + Some(_) => Err(StoreError::SchemaNotFound(format!( + "Entity '{type_id}' is not a schema" + ))), + None => Err(StoreError::SchemaNotFound(type_id.to_owned())), + } + } + /// Gets the content of a schema by its type ID. /// /// # Errors - /// Returns `StoreError::SchemaNotFound` if the schema is not found. + /// See [`Self::get_schema_entity`]. pub fn get_schema_content(&mut self, type_id: &str) -> Result { - if let Some(entity) = self.get(type_id) { - return Ok(entity.content.clone()); + Ok(self.get_schema_entity(type_id)?.content.clone()) + } + + /// Fetches an instance entity by its id. + /// + /// Well-known instances parse as GTS ids and are keyed by their normalized + /// id; anonymous instances (UUIDs, file paths) are not valid GTS ids and are + /// keyed by their raw id, so an id that fails to parse is used verbatim + /// rather than rejected. + /// + /// # Errors + /// Returns `StoreError::InstanceNotFound` if no entity exists for the id. + fn get_instance_entity(&mut self, instance_id: &str) -> Result { + let entity = self + .get(instance_id) + .cloned() + .ok_or_else(|| StoreError::InstanceNotFound(instance_id.to_owned()))?; + if entity.is_schema { + return Err(StoreError::InvalidEntity(format!( + "Entity '{instance_id}' is a schema, not an instance; \ + the id must be an instance (not ending with '~')" + ))); } - Err(StoreError::SchemaNotFound(type_id.to_owned())) + Ok(entity) } pub fn items(&self) -> impl Iterator { self.by_id.iter() } - /// Resolve all `$ref` references in a JSON Schema by inlining the referenced schemas. - /// - /// This method recursively traverses the schema, finds all `$ref` references, - /// and replaces them with the actual schema content from the store. The result - /// is a fully inlined schema with no external references. - /// - /// # Arguments - /// - /// * `schema` - The JSON Schema value that may contain `$ref` references - /// - /// # Returns - /// - /// A new `serde_json::Value` with all `$ref` references resolved and inlined. - /// - /// # Example - /// - /// ```ignore - /// use gts::GtsStore; - /// let store = GtsStore::new(); - /// - /// // Add schemas to store - /// store.add_schema_json("parent.v1~", parent_schema)?; - /// store.add_schema_json("child.v1~", child_schema_with_ref)?; - /// - /// // Resolve references - /// let inlined = store.resolve_schema_refs(&child_schema_with_ref); - /// assert!(!inlined.to_string().contains("$ref")); - /// ``` + /// Best-effort `$ref` resolution for a JSON Schema: resolvable `gts://` + /// `$ref`s are inlined, unresolved external refs are left intact. See + /// [`crate::schema_resolver::SchemaResolver`]. #[must_use] pub fn resolve_schema_refs(&self, schema: &Value) -> Value { - let mut visited = std::collections::HashSet::new(); - let mut cycle_found = false; - self.resolve_schema_refs_inner(schema, &mut visited, &mut cycle_found, false) + crate::schema_resolver::SchemaResolver::new(self).resolve(schema) } - /// Like [`resolve_schema_refs`] but returns an error if a circular `$ref` - /// is detected during resolution. + /// Like [`Self::resolve_schema_refs`] but errors on an unresolved external + /// `$ref` or a circular `$ref`. /// - /// Uses DFS-path cycle detection: a `$ref` target is held in the seen-set - /// only while its resolution is in progress on the current DFS stack and - /// removed once that subtree finishes. Re-entry into an in-progress - /// target is a true cycle. Multiple independent occurrences of the same - /// `$ref` (e.g. duplicate refs in `allOf`) are NOT flagged — redundant - /// manual aggregation across an `$id` chain is allowed. - pub(crate) fn resolve_schema_refs_checked(&self, schema: &Value) -> Result { - let mut visited = std::collections::HashSet::new(); - let mut cycle_found = false; - let resolved = - self.resolve_schema_refs_inner(schema, &mut visited, &mut cycle_found, false); - if cycle_found { - Err("circular $ref detected".to_owned()) - } else { - Ok(resolved) - } - } - - #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] - fn resolve_schema_refs_inner( - &self, - schema: &Value, - visited: &mut std::collections::HashSet, - cycle_found: &mut bool, - strict_cycles: bool, - ) -> Value { - // Recursively resolve $ref references in the schema - match schema { - Value::Object(map) => { - if let Some(Value::String(ref_uri)) = map.get("$ref") { - // Handle internal JSON Schema references like #/$defs/GtsInstanceId - // These should be inlined to match schemars 0.8 behavior (is_referenceable=false) - match ref_uri.as_str() { - "#/$defs/GtsInstanceId" => { - return crate::GtsInstanceId::json_schema_value(); - } - "#/$defs/GtsTypeId" | "#/$defs/GtsSchemaId" => { - return crate::GtsTypeId::json_schema_value(); - } - s if s.starts_with("#/") => { - // Other internal references - keep as-is - let mut new_map = serde_json::Map::new(); - for (k, v) in map { - new_map.insert( - k.clone(), - self.resolve_schema_refs_inner( - v, - visited, - cycle_found, - strict_cycles, - ), - ); - } - return Value::Object(new_map); - } - _ => {} // Fall through to external ref handling - } - - // Normalize the ref: strip gts:// prefix to get canonical GTS ID - let canonical_ref = ref_uri.strip_prefix(GTS_URI_PREFIX).unwrap_or(ref_uri); - - // Cycle detection: skip if we've already visited this ref - if visited.contains(canonical_ref) { - // Circular $ref detected — drop it to avoid infinite loop - *cycle_found = true; - let mut new_map = serde_json::Map::new(); - for (k, v) in map { - if k != "$ref" { - new_map.insert( - k.clone(), - self.resolve_schema_refs_inner( - v, - visited, - cycle_found, - strict_cycles, - ), - ); - } - } - if new_map.is_empty() { - return schema.clone(); - } - return Value::Object(new_map); - } - - // Try to resolve the reference using canonical ID - if let Some(entity) = self.by_id.get(canonical_ref) - && entity.is_schema - { - // Mark as visited before recursing - visited.insert(canonical_ref.to_owned()); - // Recursively resolve refs in the referenced schema - let mut resolved = self.resolve_schema_refs_inner( - &entity.content, - visited, - cycle_found, - strict_cycles, - ); - if !strict_cycles { - visited.remove(canonical_ref); - } - - // Remove $id and $schema from resolved content to avoid URL resolution issues - // Note: $defs for GtsInstanceId/GtsTypeId are inlined during resolution (see match above) - if let Value::Object(ref mut resolved_map) = resolved { - resolved_map.remove("$id"); - resolved_map.remove("$schema"); - } - - // If the original object has only $ref, return the resolved schema - if map.len() == 1 { - return resolved; - } - - // Otherwise, merge the resolved schema with other properties - if let Value::Object(resolved_map) = resolved { - let mut merged = resolved_map; - for (k, v) in map { - if k != "$ref" { - merged.insert( - k.clone(), - self.resolve_schema_refs_inner( - v, - visited, - cycle_found, - strict_cycles, - ), - ); - } - } - return Value::Object(merged); - } - } - // If we can't resolve, remove the $ref to avoid "relative URL" errors - // and keep other properties - let mut new_map = serde_json::Map::new(); - for (k, v) in map { - if k != "$ref" { - new_map.insert( - k.clone(), - self.resolve_schema_refs_inner( - v, - visited, - cycle_found, - strict_cycles, - ), - ); - } - } - if !new_map.is_empty() { - return Value::Object(new_map); - } - return schema.clone(); - } - - // Special handling for allOf arrays - merge $ref resolved schemas - if let Some(Value::Array(all_of_array)) = map.get("allOf") { - let mut resolved_all_of = Vec::new(); - let mut merged_properties = serde_json::Map::new(); - let mut merged_required: Vec = Vec::new(); - - for item in all_of_array { - let resolved_item = self.resolve_schema_refs_inner( - item, - visited, - cycle_found, - strict_cycles, - ); - - match resolved_item { - Value::Object(ref item_map) => { - // If this item still has a $ref, keep it in allOf - if item_map.contains_key("$ref") { - resolved_all_of.push(resolved_item); - } else { - // Merge properties and required fields from resolved items - if let Some(Value::Object(props_map)) = - item_map.get("properties") - { - for (k, v) in props_map { - merged_properties.insert(k.clone(), v.clone()); - } - } - if let Some(Value::Array(req_array)) = item_map.get("required") - { - for v in req_array { - if let Value::String(s) = v - && !merged_required.contains(s) - { - merged_required.push(s.to_owned()); - } - } - } - } - } - _ => resolved_all_of.push(resolved_item), - } - } - - // If we have merged properties, create a single schema instead of allOf - if !merged_properties.is_empty() { - let mut merged_schema = serde_json::Map::new(); - - // Copy all properties except allOf - for (k, v) in map { - if k != "allOf" { - merged_schema.insert(k.clone(), v.clone()); - } - } - - // Add merged properties and required fields - merged_schema - .insert("properties".to_owned(), Value::Object(merged_properties)); - if !merged_required.is_empty() { - merged_schema.insert( - "required".to_owned(), - Value::Array( - merged_required.into_iter().map(Value::String).collect(), - ), - ); - } - - return Value::Object(merged_schema); - } - } - - // Recursively process all properties - let mut new_map = serde_json::Map::new(); - for (k, v) in map { - new_map.insert( - k.clone(), - self.resolve_schema_refs_inner(v, visited, cycle_found, strict_cycles), - ); - } - Value::Object(new_map) - } - Value::Array(arr) => Value::Array( - arr.iter() - .map(|v| self.resolve_schema_refs_inner(v, visited, cycle_found, strict_cycles)) - .collect(), - ), - _ => schema.clone(), - } + /// # Errors + /// [`StoreError::UnresolvedRefs`] or [`StoreError::CircularRef`]. + pub fn try_resolve_schema_refs(&self, schema: &Value) -> Result { + crate::schema_resolver::SchemaResolver::new(self).try_resolve(schema) } fn remove_x_gts_ref_fields(schema: &Value) -> Value { @@ -534,192 +366,81 @@ impl GtsStore { } } - fn validate_schema_x_gts_refs(&mut self, gts_id: &str) -> Result<(), StoreError> { - if !gts_id.ends_with('~') { - return Err(StoreError::SchemaNotFound(format!( - "ID '{gts_id}' is not a schema (must end with '~')" - ))); - } - - let schema_entity = self - .get(gts_id) - .ok_or_else(|| StoreError::SchemaNotFound(gts_id.to_owned()))?; - - if !schema_entity.is_schema { - return Err(StoreError::SchemaNotFound(format!( - "Entity '{gts_id}' is not a schema" - ))); + /// Collapses a slice of x-gts-ref validation errors into a single + /// `StoreError::ValidationError`, or `Ok(())` when there are none. + fn check_x_gts_ref_errors( + errors: &[crate::x_gts_ref::XGtsRefValidationError], + ) -> Result<(), StoreError> { + if errors.is_empty() { + return Ok(()); } + let messages: Vec = errors + .iter() + .map(|err| { + if err.field_path.is_empty() { + err.reason.clone() + } else { + format!("{}: {}", err.field_path, err.reason) + } + }) + .collect(); + Err(StoreError::ValidationError(format!( + "x-gts-ref validation failed: {}", + messages.join("; ") + ))) + } - tracing::info!("Validating schema x-gts-ref fields for {}", gts_id); - - // Validate x-gts-ref constraints in the schema + fn validate_schema_x_gts_refs(schema_content: &Value) -> Result<(), StoreError> { let validator = crate::x_gts_ref::XGtsRefValidator::new(); - let x_gts_ref_errors = validator.validate_schema(&schema_entity.content, "", None); - - if !x_gts_ref_errors.is_empty() { - let error_messages: Vec = x_gts_ref_errors - .iter() - .map(|err| { - if err.field_path.is_empty() { - err.reason.clone() - } else { - format!("{}: {}", err.field_path, err.reason) - } - }) - .collect(); - let error_message = - format!("x-gts-ref validation failed: {}", error_messages.join("; ")); - return Err(StoreError::ValidationError(error_message)); - } - - Ok(()) + let x_gts_ref_errors = validator.validate_schema(schema_content, "", None); + Self::check_x_gts_ref_errors(&x_gts_ref_errors) } - /// Validates all `$ref` values in a schema. + /// Validates all `$ref` URI values in a schema. /// /// Rules: /// - Local refs (starting with `#`) are always valid /// - External refs must use `gts://` URI format /// - The GTS ID after `gts://` must be a valid GTS identifier /// + /// Delegates to [`crate::schema_refs::extract_gts_refs`], the single + /// canonical definition of what a GTS `$ref` is, so schema validation and + /// dependency extraction cannot drift. The collected dependency set is + /// discarded here - validation only cares that every `$ref` is well-formed. + /// /// # Errors /// Returns `StoreError::InvalidRef` if any `$ref` is invalid. - fn validate_schema_refs(schema: &Value, path: &str) -> Result<(), StoreError> { - match schema { - Value::Object(map) => { - // Check $ref if present - if let Some(Value::String(ref_uri)) = map.get("$ref") { - let current_path = if path.is_empty() { - "$ref".to_owned() - } else { - format!("{path}.$ref") - }; - - // Local refs (JSON Pointer) are always valid - if ref_uri.starts_with('#') { - // Valid local ref - } - // GTS refs must use gts:// URI format and target a schema - // (type) document. Only `entity.is_schema` documents are - // registered for retrieval (see `GtsRetriever::new`), so an - // instance-id ref would pass a plain `is_valid` check here - // and then fail later during retrieval. Require a type id up - // front via `GtsTypeId::try_new` (valid GTS id ending in `~`). - else if let Some(gts_id) = ref_uri.strip_prefix(GTS_URI_PREFIX) { - if crate::GtsTypeId::try_new(gts_id).is_err() { - return Err(StoreError::InvalidRef(format!( - "at '{current_path}': '{ref_uri}' must reference a GTS type id \ - (a valid identifier ending with '~'), got '{gts_id}'" - ))); - } - } - // Any other external ref is invalid - else { - return Err(StoreError::InvalidRef(format!( - "at '{current_path}': '{ref_uri}' must be a local ref (starting with '#') \ - or a GTS URI (starting with 'gts://')" - ))); - } - } - - // Recursively validate nested objects - for (key, value) in map { - if key == "$ref" { - continue; // Already validated above - } - let nested_path = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - Self::validate_schema_refs(value, &nested_path)?; - } - } - Value::Array(arr) => { - for (idx, item) in arr.iter().enumerate() { - let nested_path = format!("{path}[{idx}]"); - Self::validate_schema_refs(item, &nested_path)?; - } - } - _ => {} - } - Ok(()) + fn validate_ref_uris(schema: &Value) -> Result<(), StoreError> { + crate::schema_refs::extract_gts_refs(schema) + .map(|_| ()) + .map_err(|e| StoreError::InvalidRef(e.to_string())) } - /// Validates a schema against JSON Schema meta-schema and x-gts-ref constraints. + /// Validates every reference in a registered schema document: `$ref` URI + /// shapes (local `#` pointer or `gts://` type id — [`Self::validate_ref_uris`]) + /// and `x-gts-ref` GTS ids ([`Self::validate_schema_x_gts_refs`]). + /// + /// Pure structural check: no dependency resolution and no JSON Schema + /// meta-compilation. Meta-compilation happens in [`Self::validate_schema`] + /// once `$ref`s are inlined, so this is safe to run at registration time + /// even when forward references are not yet registered. /// /// # Errors - /// Returns `StoreError` if validation fails. - pub fn validate_schema(&mut self, gts_id: &str) -> Result<(), StoreError> { - if !gts_id.ends_with('~') { - return Err(StoreError::SchemaNotFound(format!( - "ID '{gts_id}' is not a schema (must end with '~')" - ))); - } - - let schema_entity = self - .get(gts_id) - .ok_or_else(|| StoreError::SchemaNotFound(gts_id.to_owned()))?; - - if !schema_entity.is_schema { - return Err(StoreError::SchemaNotFound(format!( - "Entity '{gts_id}' is not a schema" - ))); - } - - let schema_content = schema_entity.content.clone(); + /// `StoreError::SchemaNotFound` if the id is missing or its content is not + /// an object; `StoreError::InvalidRef`/`ValidationError` for a malformed + /// `$ref` or `x-gts-ref`. + pub(crate) fn validate_schema_refs(&mut self, gts_id: &str) -> Result<(), StoreError> { + let schema_content = self.get_schema_content(gts_id)?; if !schema_content.is_object() { return Err(StoreError::SchemaNotFound(format!( "Schema '{gts_id}' content must be a dictionary" ))); } - tracing::info!("Validating schema {}", gts_id); - - // 1. Validate $ref fields - must be local (#...) or gts:// URIs - Self::validate_schema_refs(&schema_content, "")?; - - // 2. Validate x-gts-ref fields (before JSON Schema validation) - // This ensures we catch invalid GTS IDs in x-gts-ref before the JSON Schema - // compiler potentially fails on them - self.validate_schema_x_gts_refs(gts_id)?; - - // 3. Validate against JSON Schema meta-schema - // We need to remove x-gts-ref fields before compiling because the jsonschema - // crate doesn't understand them and will fail on JSON Pointer references - let mut schema_for_validation = Self::remove_x_gts_ref_fields(&schema_content); - - // Check if schema contains gts:// references - let has_gts_refs = schema_for_validation.to_string().contains("gts://"); - - if has_gts_refs { - // Skip jsonschema compilation for schemas with gts:// references during registration - // This allows forward references (schemas referencing other schemas that don't exist yet) - // Full validation with reference resolution will happen during instance validation - tracing::debug!( - "Schema {} contains gts:// references, skipping compilation during registration", - gts_id - ); - } else { - // For schemas without gts:// references, validate the structure - // Remove $id and $schema to avoid URL resolution issues - if let Value::Object(ref mut map) = schema_for_validation { - map.remove("$id"); - map.remove("$schema"); - } - - jsonschema::validator_for(&schema_for_validation).map_err(|e| { - StoreError::ValidationError(format!( - "JSON Schema validation failed for '{gts_id}': {e}" - )) - })?; - } - - tracing::info!( - "Schema {} passed JSON Schema meta-schema validation", - gts_id - ); + // `$ref` URIs must be local (#...) or gts:// type ids. + Self::validate_ref_uris(&schema_content)?; + // `x-gts-ref` values must be valid GTS ids. + Self::validate_schema_x_gts_refs(&schema_content)?; Ok(()) } @@ -795,13 +516,13 @@ impl GtsStore { })?; let base_resolved = self - .resolve_schema_refs_checked(&base_content) + .try_resolve_schema_refs(&base_content) .map_err(|e| StoreError::ValidationError(format!("Schema '{base_id}' has {e}")))?; - let derived_resolved = - self.resolve_schema_refs_checked(&derived_content) - .map_err(|e| { - StoreError::ValidationError(format!("Schema '{derived_id}' has {e}")) - })?; + let derived_resolved = self + .try_resolve_schema_refs(&derived_content) + .map_err(|e| { + StoreError::ValidationError(format!("Schema '{derived_id}' has {e}")) + })?; // Extract effective schemas and compare via schema_compat module let base_eff = crate::schema_compat::extract_effective_schema(&base_resolved); @@ -827,34 +548,51 @@ impl GtsStore { Ok(()) } - /// OP#13: Validates schema traits across the inheritance chain. + /// `true` when a schema document declares `x-gts-abstract: true`. + pub(crate) fn content_is_abstract(content: &Value) -> bool { + content.get(crate::schema_modifiers::X_GTS_ABSTRACT) == Some(&Value::Bool(true)) + } + + /// `true` when a schema document declares `x-gts-final: true`. + pub(crate) fn content_is_final(content: &Value) -> bool { + content.get(crate::schema_modifiers::X_GTS_FINAL) == Some(&Value::Bool(true)) + } + + /// Wrap trait-validation error messages in a `StoreError` tagged with the + /// offending type id — the single home for this phrasing. + fn wrap_trait_error(gts_id: &str, errors: &[String]) -> StoreError { + StoreError::ValidationError(format!( + "Schema '{gts_id}' trait validation failed: {}", + errors.join("; ") + )) + } + + /// Build the [`EffectiveTraits`](crate::schema_traits::EffectiveTraits) for + /// `type_id` by walking its `$id` chain (root → leaf). /// - /// Walks the chain from base to leaf, collects `x-gts-traits-schema` and - /// `x-gts-traits` from each level's **raw** content (before allOf - /// flattening which would drop `x-gts-*` keys), resolves `$ref` inside - /// collected trait schemas, then validates. + /// Collects `x-gts-traits-schema` subschemas and `x-gts-traits` values from + /// each level's **raw** content (before `resolve_schema_refs` flattens + /// `allOf` and drops the `x-gts-*` extension keys), inlines JSON Pointer + /// `$ref`s against their host document, resolves any `gts://` `$ref`s inside + /// the collected subschemas, RFC 7396-merges the values (descendant + /// last-wins for scalars/arrays, recursive merge for objects, `null` deletes + /// the key), then composes the effective trait-schema and materializes the + /// values. The leaf's `$schema` dialect is re-injected into the composed + /// schema. Used by [`Self::validate_schema`] (OP#13) and + /// [`crate::ops::GtsOps`]'s entity-level trait check. /// /// # Errors - /// Returns `StoreError::ValidationError` if trait validation fails. - pub(crate) fn validate_schema_traits(&mut self, gts_id: &str) -> Result<(), StoreError> { - let gid = GtsId::try_new(gts_id) + /// `StoreError::ValidationError` if the id is invalid, an ancestor schema is + /// missing, or a `$ref` inside a trait schema fails to resolve. + pub(crate) fn effective_traits( + &mut self, + type_id: &str, + ) -> Result { + let gid = GtsId::try_new(type_id) .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; - let segments = &gid.segments(); - // Collect raw trait schemas and trait values from every schema in the chain. - // We use *raw* content because resolve_schema_refs flattens allOf and only - // keeps `properties`/`required`, dropping extension keys like x-gts-*. - // - // x-gts-traits-schema declarations along the $id chain are composed via - // allOf into a single effective trait-schema (handled in - // schema_traits::build_effective_trait_schema). x-gts-traits values along - // the chain are merged per RFC 7396 JSON Merge Patch (descendant last-wins - // for scalars/arrays, recursive merge for objects, `null` deletes the - // key). The publisher locks a value via standard JSON Schema `const` in - // x-gts-traits-schema; the registry carries no GTS-specific immutability - // rule. - let mut trait_schemas: Vec = Vec::new(); + let mut trait_schemas: Vec = Vec::new(); let mut merged_traits = serde_json::Map::new(); for i in 0..segments.len() { @@ -873,156 +611,179 @@ impl GtsStore { )) })?; - // Collect x-gts-traits-schema from the raw content (object | true | false). - crate::schema_traits::collect_trait_schema_from_value(&content, &mut trait_schemas); + // Collect this level's trait schemas, then inline any JSON Pointer + // (`#/...`) `$ref`s against this host document (`content`) while it + // is still the document root — see `inline_local_pointers`. + let mut level_trait_schemas: Vec = Vec::new(); + crate::schema_traits::collect_trait_schema_from_value( + &content, + &mut level_trait_schemas, + ); + for ts in level_trait_schemas { + trait_schemas.push(crate::schema_traits::inline_local_pointers(&ts, &content)); + } - // Collect x-gts-traits from the raw content, then RFC 7396-merge - // them into the accumulated merged_traits. let mut level_traits = serde_json::Map::new(); crate::schema_traits::collect_traits_from_value(&content, &mut level_traits); - tracing::debug!( - "validate_schema_traits [{schema_id}]: level_traits={:?}", - level_traits.keys().collect::>(), - ); crate::schema_traits::merge_rfc7396_into(&mut merged_traits, &level_traits); } - // Resolve $ref inside each collected trait schema so that external - // references (e.g. gts://gts.x.test13.traits.retention.v1~) are inlined. - let mut resolved_trait_schemas: Vec = - Vec::with_capacity(trait_schemas.len()); + let mut resolved_trait_schemas: Vec = Vec::with_capacity(trait_schemas.len()); for ts in &trait_schemas { - let resolved = self.resolve_schema_refs_checked(ts).map_err(|e| { - StoreError::ValidationError(format!("Schema '{gts_id}' trait schema has {e}")) + let resolved = self.try_resolve_schema_refs(ts).map_err(|e| { + StoreError::ValidationError(format!("Schema '{type_id}' trait schema has {e}")) })?; resolved_trait_schemas.push(resolved); } - // Check if the leaf schema is abstract — skip trait validation entirely. - // Abstract schemas are not leaf schemas, so trait resolution completeness is not enforced. - // While we hold the leaf entity, capture its `$schema` dialect so trait - // values validate under the same JSON Schema draft as the host document - // (a GTS Type Schema always declares `$schema`). - let dialect = if let Some(leaf_entity) = self.get(gts_id) { - if leaf_entity - .content - .get(crate::schema_modifiers::X_GTS_ABSTRACT) - == Some(&Value::Bool(true)) - { - return Ok(()); - } - leaf_entity - .content - .get("$schema") - .and_then(Value::as_str) - .map(str::to_owned) - } else { - None - }; + // Dialect comes from the leaf document's `$schema`, re-injected into the + // composed trait schema because the inline fragment had its root-only + // `$schema` stripped when embedded. + let dialect = self + .get(type_id) + .and_then(|leaf| leaf.content.get("$schema").and_then(Value::as_str)) + .map(str::to_owned); - // Delegate to the schema_traits module - let merged = serde_json::Value::Object(merged_traits); - crate::schema_traits::validate_effective_traits( + Ok(crate::schema_traits::build_effective_traits( &resolved_trait_schemas, - &merged, - true, + &Value::Object(merged_traits), dialect.as_deref(), - ) - .map_err(|errors| { + )) + } + + /// Fully validate a registered type schema and return its resolved + /// [`ResolvedType`] in a single pass. Every type it depends on (its + /// `$id`-chain ancestors and the targets of its `gts://` `$ref`s) must + /// already be registered. + /// + /// Pipeline: + /// 1. [`Self::validate_schema_refs`] — `$ref`/`x-gts-ref` structure; + /// 2. [`crate::schema_modifiers::validate_gts_keywords`] — format and + /// top-level placement of `x-gts-final`/`x-gts-abstract`/`x-gts-traits`/ + /// `x-gts-traits-schema`; + /// 3. [`Self::validate_schema_chain`] — derived-vs-base compatibility (OP#12); + /// 4. resolve: inline `#/` and `gts://` `$ref`s into a self-contained body; + /// 5. meta-compile the resolved body against JSON Schema — registration + /// defers this whenever raw `gts://` refs are present, so it is done here + /// once every dependency is inlined, catching malformed schema bodies; + /// 6. build the effective traits schema/values **exactly once** and validate + /// them (OP#13): provided trait values are always type/enum/`x-gts-ref` + /// checked; the required-trait completeness check is skipped for abstract + /// leaves. + /// + /// Abstract types still type-check any trait values they provide, but skip + /// the OP#13 completeness check (a descendant closes the required traits). + /// + /// Uncached: a consumer that calls this repeatedly for the same `type_id` + /// should cache the result (safe forever — versioned ids are immutable). + /// [`crate::ops::GtsOps::validate_schema`] wraps this for the + /// `/validate-type-schema` endpoint, discarding the resolved artifacts. + /// + /// # Errors + /// `StoreError::ValidationError` if any validation stage fails or a + /// dependency is missing from the store; `StoreError::SchemaNotFound` if the + /// type is not registered. + pub fn validate_schema(&mut self, type_id: &str) -> Result { + self.validate_schema_refs(type_id)?; + + let content = self.get_schema_content(type_id)?; + crate::schema_modifiers::validate_gts_keywords(&content) + .map_err(StoreError::ValidationError)?; + + self.validate_schema_chain(type_id)?; + + let resolved_schema = self + .try_resolve_schema_refs(&content) + .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; + + // Meta-validate the fully-resolved schema. Registration only checks + // `$ref`/`x-gts-ref` structure (see `validate_schema_refs`); now that + // every dependency is inlined we can compile the resolved body and catch + // malformed schema structure outside the refs. + let mut schema_for_validation = Self::remove_x_gts_ref_fields(&resolved_schema); + if let Value::Object(ref mut map) = schema_for_validation { + map.remove("$id"); + map.remove("$schema"); + } + jsonschema::validator_for(&schema_for_validation).map_err(|e| { StoreError::ValidationError(format!( - "Schema '{}' trait validation failed: {}", - gts_id, - errors.join("; ") + "JSON Schema validation failed for '{type_id}': {e}" )) + })?; + + // Trait values are always validated against the effective trait-schema + // (type/enum/`x-gts-ref` conformance), even for abstract types. Only the + // required-trait *completeness* check is gated: an abstract type may + // leave a required trait unresolved for a descendant to supply, so it is + // validated with `check_unresolved = false`. + let is_abstract = Self::content_is_abstract(&content); + let traits = self.effective_traits(type_id)?; + traits + .validate(!is_abstract) + .map_err(|errors| Self::wrap_trait_error(type_id, &errors))?; + + Ok(ResolvedType { + id: crate::GtsTypeId::try_new(type_id).map_err(StoreError::InvalidTypeId)?, + is_abstract, + is_final: Self::content_is_final(&content), + schema: resolved_schema, + effective_traits: traits.values, + effective_traits_schema: traits.schema, }) } - /// OP#13 entity-level check: ensures the effective trait schema is "closed". + /// Validate a caller-supplied instance payload against `type_id`'s schema. /// - /// For a schema to be a valid standalone entity, every `x-gts-traits-schema` - /// in the chain must set `additionalProperties: false`. An open trait schema - /// signals that the schema is designed to be extended and is not a deployable - /// entity. Additionally, if a trait schema is defined but no `x-gts-traits` - /// values exist anywhere in the chain, the entity is incomplete. - pub(crate) fn validate_entity_traits(&mut self, gts_id: &str) -> Result<(), StoreError> { - let gid = GtsId::try_new(gts_id) - .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; - - // If the type named by `gts_id` is abstract, it is exempt from the OP#13 - // entity-level checks (completeness + closed trait schema). An abstract - // type is a template, not a deployable standalone entity: it may carry an - // `x-gts-traits-schema` without resolving its required traits, and its - // descendants are expected to close them. The exemption is keyed on - // `x-gts-abstract` specifically — `x-gts-final` types are non-abstract and - // MUST satisfy completeness themselves (gts-spec §9.7.5 / §9.11.4, - // ADR-0003). Mirrors the abstract skip in `validate_schema_traits`. - if let Some(leaf_entity) = self.get(gts_id) - && leaf_entity - .content - .get(crate::schema_modifiers::X_GTS_ABSTRACT) - == Some(&Value::Bool(true)) - { - return Ok(()); + /// Stateless: no registered instance is required, but the type and its + /// `$ref`/chain dependencies must be registered. Rejects abstract types + /// (OP#6) and enforces `x-gts-ref`. + /// + /// # Errors + /// `StoreError::ValidationError` on schema-compile failure, JSON Schema + /// validation failure, abstract type, or `x-gts-ref` violation; + /// `StoreError::SchemaNotFound` if the type is not registered. + pub fn validate_payload(&mut self, type_id: &str, payload: &Value) -> Result<(), StoreError> { + let content = self.get_schema_content(type_id)?; + + // Abstract types cannot have direct instances (OP#6). + if Self::content_is_abstract(&content) { + return Err(StoreError::ValidationError(format!( + "type '{type_id}' is abstract and cannot have direct instances" + ))); } - let segments = &gid.segments(); - - let mut trait_schemas: Vec = Vec::new(); - let mut has_trait_values = false; - - for i in 0..segments.len() { - let schema_id = format!( - "gts.{}", - segments[..=i] - .iter() - .map(gts_id::GtsIdSegment::raw) - .collect::>() - .join("") - ); + // Payload validation needs only the resolved type body — traits are + // schema-level metadata (§9.7) and never appear in instances, so the + // effective-traits build is deliberately skipped here. + let resolved_schema = self + .try_resolve_schema_refs(&content) + .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; - let content = self.get_schema_content(&schema_id).map_err(|_| { - StoreError::ValidationError(format!( - "Schema '{schema_id}' not found for entity trait validation" - )) + // Strip x-gts-ref before compiling (unknown keyword to jsonschema); keep + // a retriever for any residual gts:// refs, mirroring validate_instance. + let schema_for_validation = Self::remove_x_gts_ref_fields(&resolved_schema); + let retriever = GtsRetriever::new(&self.by_id); + let validator = jsonschema::options() + .with_retriever(retriever) + .build(&schema_for_validation) + .map_err(|e| { + StoreError::ValidationError(format!("Invalid schema for '{type_id}': {e}")) })?; - crate::schema_traits::collect_trait_schema_from_value(&content, &mut trait_schemas); - - let mut level_traits = serde_json::Map::new(); - crate::schema_traits::collect_traits_from_value(&content, &mut level_traits); - if !level_traits.is_empty() { - has_trait_values = true; - } - } - - if trait_schemas.is_empty() { - return Ok(()); - } - - // If trait schemas exist but no trait values are provided, the entity - // is incomplete. - if !has_trait_values { - return Err(StoreError::ValidationError( - "Entity defines x-gts-traits-schema but no x-gts-traits values are provided" - .to_owned(), - )); + let errors: Vec = validator + .iter_errors(payload) + .map(|e| e.to_string()) + .collect(); + if !errors.is_empty() { + return Err(StoreError::ValidationError(format!( + "Validation failed: {}", + errors.join(", ") + ))); } - // Each trait schema must be closed (additionalProperties: false) - for ts in &trait_schemas { - if let Some(obj) = ts.as_object() { - match obj.get("additionalProperties") { - Some(serde_json::Value::Bool(false)) => {} // closed — ok - _ => { - return Err(StoreError::ValidationError( - "Entity trait schema must set additionalProperties: false \ - to be a valid standalone entity" - .to_owned(), - )); - } - } - } - } + let xref = crate::x_gts_ref::XGtsRefValidator::new(); + let xref_errors = xref.validate_instance(payload, &resolved_schema, ""); + Self::check_x_gts_ref_errors(&xref_errors)?; Ok(()) } @@ -1032,33 +793,11 @@ impl GtsStore { /// # Errors /// Returns `StoreError` if validation fails. pub fn validate_instance(&mut self, instance_id: &str) -> Result<(), StoreError> { - // Try to parse as GTS ID first (for well-known instances) - // If that fails, use the instance_id directly (for anonymous instances with UUIDs) - let lookup_id = if let Ok(gid) = GtsId::try_new(instance_id) { - gid.id().to_owned() - } else { - instance_id.to_owned() - }; - - let obj = self - .get(&lookup_id) - .ok_or_else(|| StoreError::ObjectNotFound(instance_id.to_owned()))? - .clone(); - - let type_id = obj - .type_id - .as_ref() - .ok_or_else(|| StoreError::SchemaForInstanceNotFound(lookup_id.clone()))? - .clone(); - - let schema = self.get_schema_content(&type_id)?; + let obj = self.get_instance_entity(instance_id)?; - // Check x-gts-abstract: abstract types cannot have direct instances. - if schema.get(crate::schema_modifiers::X_GTS_ABSTRACT) == Some(&Value::Bool(true)) { - return Err(StoreError::ValidationError(format!( - "type '{type_id}' is abstract and cannot have direct instances" - ))); - } + let type_id = obj.type_id.as_ref().ok_or_else(|| { + StoreError::InvalidEntity(format!("Instance '{instance_id}' has no type_id")) + })?; tracing::info!( "Validating instance {} against schema {}", @@ -1066,70 +805,9 @@ impl GtsStore { type_id ); - // Resolve internal #/ references (like #/$defs/GtsInstanceId) by inlining them - // This handles the compile-time inlining of GtsInstanceId and GtsTypeId - let schema_with_internal_refs_resolved = self.resolve_schema_refs(&schema); - - // Remove x-gts-ref fields before jsonschema validation. - // x-gts-ref is a GTS extension unknown to the jsonschema crate; leaving it - // inside oneOf/anyOf/allOf branches would cause those branches to be treated - // as empty match-everything schemas, breaking combinator semantics. - let schema_with_internal_refs_resolved = - Self::remove_x_gts_ref_fields(&schema_with_internal_refs_resolved); - - tracing::debug!( - "Schema for validation: {}", - serde_json::to_string_pretty(&schema_with_internal_refs_resolved).unwrap_or_default() - ); - - // Create custom retriever for gts:// URI resolution - let retriever = GtsRetriever::new(&self.by_id); - - // Build validator with custom retriever to handle gts:// references - // Internal #/ references have already been resolved by resolve_schema_refs - // The retriever will resolve any $ref to gts:// URIs automatically - let validator = jsonschema::options() - .with_retriever(retriever) - .build(&schema_with_internal_refs_resolved) - .map_err(|e| { - tracing::error!("Schema compilation error: {}", e); - StoreError::ValidationError(format!( - "Invalid schema: {e}\nContent: {}\nSchema: {}", - serde_json::to_string_pretty(&obj.content).unwrap_or_default(), - serde_json::to_string_pretty(&schema_with_internal_refs_resolved) - .unwrap_or_default() - )) - })?; - - validator.validate(&obj.content).map_err(|_| { - let errors: Vec = validator - .iter_errors(&obj.content) - .map(|err| err.to_string()) - .collect(); - StoreError::ValidationError(format!("Validation failed: {}", errors.join(", "))) - })?; - - // Validate x-gts-ref constraints - let validator = crate::x_gts_ref::XGtsRefValidator::new(); - let x_gts_ref_errors = validator.validate_instance(&obj.content, &schema, ""); - - if !x_gts_ref_errors.is_empty() { - let error_messages: Vec = x_gts_ref_errors - .iter() - .map(|err| { - if err.field_path.is_empty() { - err.reason.clone() - } else { - format!("{}: {}", err.field_path, err.reason) - } - }) - .collect(); - let error_message = - format!("x-gts-ref validation failed: {}", error_messages.join("; ")); - return Err(StoreError::ValidationError(error_message)); - } - - Ok(()) + // A registered instance is just a stored payload; validation is identical + // to validating a caller-supplied payload against its declared type. + self.validate_payload(type_id, &obj.content) } /// Casts an entity from one schema to another. @@ -1138,50 +816,23 @@ impl GtsStore { /// Returns `StoreError` if the cast fails. pub fn cast( &mut self, - from_id: &str, + instance_id: &str, target_type_id: &str, ) -> Result { - let from_entity = self - .get(from_id) - .ok_or_else(|| StoreError::EntityNotFound(from_id.to_owned()))? - .clone(); - - if from_entity.is_schema { - return Err(StoreError::CastFromSchemaNotAllowed(from_id.to_owned())); - } + let instance = self.get_instance_entity(instance_id)?; + let instance_type_id = instance.type_id.clone().ok_or_else(|| { + StoreError::InvalidEntity(format!("Instance '{instance_id}' has no type_id")) + })?; + let from_schema = self.get_schema_entity(&instance_type_id)?.clone(); - let to_schema = self - .get(target_type_id) - .ok_or_else(|| StoreError::ObjectNotFound(target_type_id.to_owned()))? - .clone(); - - // Get the source schema - let (from_schema, _from_type_id) = if from_entity.is_schema { - let id = from_entity - .gts_id - .as_ref() - .ok_or(StoreError::InvalidEntity)? - .id() - .to_owned(); - (from_entity.clone(), id) - } else { - let type_id = from_entity - .type_id - .as_ref() - .ok_or_else(|| StoreError::SchemaForInstanceNotFound(from_id.to_owned()))?; - let schema = self - .get(type_id) - .ok_or_else(|| StoreError::ObjectNotFound(type_id.clone()))? - .clone(); - (schema, type_id.clone()) - }; + let target_schema = self.get_schema_entity(target_type_id)?.clone(); // Create a resolver to handle $ref in schemas // TODO: Implement custom resolver let resolver = None; - from_entity - .cast(&to_schema, &from_schema, resolver) + instance + .cast(&target_schema, &from_schema, resolver) .map_err(|e| StoreError::SchemaNotFound(e.to_string())) } diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index 9509bfc..630563b 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; use crate::entities::{GtsConfig, GtsEntity}; -use serde_json::json; +use serde_json::{Value, json}; #[test] fn test_gts_store_query_result_default() { @@ -36,13 +36,13 @@ fn test_gts_store_query_result_serialization() { #[test] fn test_gts_store_new_without_reader() { - let store: GtsStore = GtsStore::new(None); + let store: GtsStore = GtsStore::new(); assert_eq!(store.items().count(), 0); } #[test] fn test_gts_store_register_entity() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -69,7 +69,7 @@ fn test_gts_store_register_entity() { #[test] fn test_gts_store_register_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_content = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -91,7 +91,7 @@ fn test_gts_store_register_schema() { #[test] fn test_gts_store_register_schema_invalid_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_content = json!({ "type": "object" @@ -104,14 +104,14 @@ fn test_gts_store_register_schema_invalid_id() { assert!(result.is_err()); match result { - Err(StoreError::InvalidSchemaId) => {} - _ => panic!("Expected InvalidSchemaId error"), + Err(StoreError::InvalidTypeId(_)) => {} + _ => panic!("Expected InvalidTypeId error"), } } #[test] fn test_gts_store_get_schema_content() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_content = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -130,13 +130,13 @@ fn test_gts_store_get_schema_content() { #[test] fn test_gts_store_get_schema_content_not_found() { - let mut store = GtsStore::new(None); - let result = store.get_schema_content("nonexistent~"); + let mut store = GtsStore::new(); + let result = store.get_schema_content("gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); match result { Err(StoreError::SchemaNotFound(id)) => { - assert_eq!(id, "nonexistent~"); + assert_eq!(id, "gts.vendor.package.namespace.type.v1.0~"); } _ => panic!("Expected SchemaNotFound error"), } @@ -144,7 +144,7 @@ fn test_gts_store_get_schema_content_not_found() { #[test] fn test_gts_store_items_iterator() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Add schemas which are easier to register for i in 0..3 { @@ -170,7 +170,7 @@ fn test_gts_store_items_iterator() { #[test] fn test_gts_store_validate_instance_missing_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); // Add an entity without a schema @@ -200,7 +200,7 @@ fn test_gts_store_validate_instance_missing_schema() { #[test] fn test_gts_store_build_schema_graph() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_content = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -220,7 +220,7 @@ fn test_gts_store_build_schema_graph() { #[test] fn test_gts_store_query_wildcard() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Add multiple schemas for i in 0..3 { @@ -245,7 +245,7 @@ fn test_gts_store_query_wildcard() { #[test] fn test_gts_store_query_with_limit() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Add 5 schemas for i in 0..5 { @@ -272,24 +272,19 @@ fn test_gts_store_query_with_limit() { #[test] fn test_store_error_display() { - let error = StoreError::ObjectNotFound("test_id".to_owned()); + let error = StoreError::InstanceNotFound("test_id".to_owned()); assert!(error.to_string().contains("test_id")); let error = StoreError::SchemaNotFound("schema_id".to_owned()); assert!(error.to_string().contains("schema_id")); - let error = StoreError::EntityNotFound("entity_id".to_owned()); - assert!(error.to_string().contains("entity_id")); - - let error = StoreError::SchemaForInstanceNotFound("instance_id".to_owned()); + let error = StoreError::InvalidEntity("instance_id".to_owned()); assert!(error.to_string().contains("instance_id")); } -// Note: resolve_schema_refs is a private method, tested indirectly through validate_instance() - #[test] fn test_gts_store_cast() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register schemas let schema_v1 = json!({ @@ -346,13 +341,20 @@ fn test_gts_store_cast() { "gts.vendor.package.namespace.type.v1.1~", ); - // Just verify it executes - assert!(result.is_ok() || result.is_err()); + let cast = result.expect("cast to a minor-compatible version should succeed"); + let casted = cast + .casted_entity + .expect("a successful cast must produce a casted entity"); + assert_eq!( + casted.get("name").and_then(Value::as_str), + Some("John"), + "the cast must carry the existing `name` value forward" + ); } #[test] fn test_gts_store_cast_missing_entity() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let result = store.cast("nonexistent", "gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); @@ -360,7 +362,7 @@ fn test_gts_store_cast_missing_entity() { #[test] fn test_gts_store_cast_missing_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -388,7 +390,7 @@ fn test_gts_store_cast_missing_schema() { #[test] fn test_gts_store_is_minor_compatible() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_v1 = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -427,7 +429,7 @@ fn test_gts_store_is_minor_compatible() { #[test] fn test_gts_store_get() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -455,14 +457,14 @@ fn test_gts_store_get() { #[test] fn test_gts_store_get_nonexistent() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let result = store.get("nonexistent"); assert!(result.is_none()); } #[test] fn test_gts_store_query_exact_match() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -480,7 +482,7 @@ fn test_gts_store_query_exact_match() { #[test] fn test_gts_store_register_duplicate() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -521,7 +523,7 @@ fn test_gts_store_register_duplicate() { #[test] fn test_gts_store_validate_instance_success() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -564,14 +566,14 @@ fn test_gts_store_validate_instance_success() { #[test] fn test_gts_store_validate_instance_missing_entity() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let result = store.validate_instance("nonexistent"); assert!(result.is_err()); } #[test] fn test_gts_store_validate_instance_no_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -594,12 +596,16 @@ fn test_gts_store_validate_instance_no_schema() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - assert!(result.is_err()); + let err = result.expect_err("an instance with no resolvable type_id must fail validation"); + assert!( + matches!(err, StoreError::InvalidEntity(ref m) if m.contains("has no type_id")), + "expected InvalidEntity(\"...has no type_id\"), got: {err:?}" + ); } #[test] fn test_gts_store_register_schema_with_invalid_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "invalid", @@ -613,14 +619,14 @@ fn test_gts_store_register_schema_with_invalid_id() { #[test] fn test_gts_store_get_schema_content_missing() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let result = store.get_schema_content("nonexistent~"); assert!(result.is_err()); } #[test] fn test_gts_store_query_empty() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let result = store.query("gts.vendor.*", 10); assert_eq!(result.count, 0); assert_eq!(result.results.len(), 0); @@ -628,13 +634,13 @@ fn test_gts_store_query_empty() { #[test] fn test_gts_store_items_empty() { - let store = GtsStore::new(None); + let store = GtsStore::new(); assert_eq!(store.items().count(), 0); } #[test] fn test_gts_store_register_entity_without_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let content = json!({ "name": "test" @@ -658,20 +664,20 @@ fn test_gts_store_register_entity_without_id() { #[test] fn test_gts_store_build_schema_graph_missing() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let graph = store.build_schema_graph("nonexistent~"); assert!(graph.is_object()); } #[test] fn test_gts_store_new_empty() { - let store = GtsStore::new(None); + let store = GtsStore::new(); assert_eq!(store.items().count(), 0); } #[test] fn test_gts_store_cast_entity_without_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -697,19 +703,23 @@ fn test_gts_store_cast_entity_without_schema() { "gts.vendor.package.namespace.type.v1.0", "gts.vendor.package.namespace.type.v1.1~", ); - assert!(result.is_err()); + let err = result.expect_err("casting an instance with no type_id must fail"); + assert!( + matches!(err, StoreError::InvalidEntity(ref m) if m.contains("has no type_id")), + "expected InvalidEntity(\"...has no type_id\"), got: {err:?}" + ); } #[test] fn test_gts_store_is_minor_compatible_missing_schemas() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let result = store.is_minor_compatible("nonexistent1~", "nonexistent2~"); assert!(!result.is_backward_compatible); } #[test] fn test_gts_store_validate_instance_with_refs() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register base schema let base_schema = json!({ @@ -765,13 +775,12 @@ fn test_gts_store_validate_instance_with_refs() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - // Just verify it executes - assert!(result.is_ok() || result.is_err()); + result.expect("a valid instance against an allOf+$ref schema should validate"); } #[test] fn test_gts_store_validate_instance_validation_failure() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -814,7 +823,7 @@ fn test_gts_store_validate_instance_validation_failure() { #[test] fn test_gts_store_query_with_filters() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); for i in 0..5 { let schema = json!({ @@ -837,7 +846,7 @@ fn test_gts_store_query_with_filters() { #[test] fn test_gts_store_register_multiple_schemas() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); for i in 0..10 { let schema = json!({ @@ -858,7 +867,7 @@ fn test_gts_store_register_multiple_schemas() { #[test] fn test_gts_store_cast_with_validation() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_v1 = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -914,12 +923,20 @@ fn test_gts_store_cast_with_validation() { "gts.vendor.package.namespace.type.v1.1~", ); - assert!(result.is_ok() || result.is_err()); + let cast = result.expect("casting to a compatible minor version should succeed"); + let casted = cast + .casted_entity + .expect("a successful cast must produce a casted entity"); + assert_eq!( + casted.get("name").and_then(Value::as_str), + Some("John"), + "the cast must carry the existing required `name` value forward" + ); } #[test] fn test_gts_store_build_schema_graph_with_refs() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base_schema = json!({ "$id": "gts://gts.vendor.package.namespace.base.v1.0~", @@ -951,7 +968,7 @@ fn test_gts_store_build_schema_graph_with_refs() { #[test] fn test_gts_store_get_schema_content_success() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -981,7 +998,7 @@ fn test_gts_store_get_schema_content_success() { #[test] fn test_gts_store_register_entity_with_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let schema = json!({ @@ -1032,16 +1049,16 @@ fn test_gts_store_query_result_structure() { #[test] fn test_gts_store_error_variants() { - let err1 = StoreError::InvalidEntity; + let err1 = StoreError::InvalidEntity("bad entity".to_owned()); assert!(!err1.to_string().is_empty()); - let err2 = StoreError::InvalidSchemaId; + let err2 = StoreError::InvalidTypeId(GtsIdError::new("bad", "not a type id")); assert!(!err2.to_string().is_empty()); } #[test] fn test_gts_store_register_schema_overwrite() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema1 = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1083,7 +1100,7 @@ fn test_gts_store_register_schema_overwrite() { #[test] fn test_gts_store_cast_missing_source_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let schema = json!({ @@ -1119,12 +1136,20 @@ fn test_gts_store_cast_missing_source_schema() { "gts.vendor.package.namespace.type.v1.0", "gts.vendor.package.namespace.type.v1.1~", ); - assert!(result.is_err()); + let err = result.expect_err("casting when the source schema is unregistered must fail"); + assert!( + matches!( + err, + StoreError::SchemaNotFound(ref m) + if m.contains("gts.vendor.package.namespace.type.v1.0~") + ), + "expected SchemaNotFound for the missing source schema, got: {err:?}" + ); } #[test] fn test_gts_store_query_multiple_patterns() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema1 = json!({ "$id": "gts://gts.vendor1.package.namespace.type.v1.0~", @@ -1157,7 +1182,7 @@ fn test_gts_store_query_multiple_patterns() { #[test] fn test_gts_store_validate_with_nested_refs() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.vendor.package.namespace.base.v1.0~", @@ -1228,12 +1253,12 @@ fn test_gts_store_validate_with_nested_refs() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.top.v1.0"); - assert!(result.is_ok() || result.is_err()); + result.expect("a valid instance against a multi-level allOf+$ref chain should validate"); } #[test] fn test_gts_store_query_with_version_wildcard() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); for i in 0..3 { let schema = json!({ @@ -1256,7 +1281,7 @@ fn test_gts_store_query_with_version_wildcard() { #[test] fn test_gts_store_cast_backward_incompatible() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_v1 = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1310,12 +1335,20 @@ fn test_gts_store_cast_backward_incompatible() { "gts.vendor.package.namespace.type.v2.0~", ); - assert!(result.is_ok() || result.is_err()); + let cast = result.expect("cast returns a compatibility report even when incompatible"); + assert!( + !cast.is_backward_compatible, + "adding required `age` must make the cast backward-incompatible" + ); + assert!( + !cast.backward_errors.is_empty(), + "backward incompatibility must be explained in backward_errors" + ); } #[test] fn test_gts_store_items_iterator_multiple() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); for i in 0..5 { let schema = json!({ @@ -1338,7 +1371,7 @@ fn test_gts_store_items_iterator_multiple() { #[test] fn test_gts_store_compatibility_fully_compatible() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_v1 = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1377,7 +1410,7 @@ fn test_gts_store_compatibility_fully_compatible() { #[test] fn test_gts_store_build_schema_graph_complex() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base1 = json!({ "$id": "gts://gts.vendor.package.namespace.base1.v1.0~", @@ -1422,7 +1455,7 @@ fn test_gts_store_build_schema_graph_complex() { #[test] fn test_gts_store_register_invalid_json_entity() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let content = json!({"name": "test"}); let entity = GtsEntity::new( @@ -1443,7 +1476,7 @@ fn test_gts_store_register_invalid_json_entity() { #[test] fn test_gts_store_validate_with_complex_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1490,13 +1523,12 @@ fn test_gts_store_validate_with_complex_schema() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - // Just verify it executes - assert!(result.is_ok() || result.is_err()); + result.expect("a fully-valid instance against the complex schema should validate"); } #[test] fn test_gts_store_validate_missing_required_field() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1537,7 +1569,7 @@ fn test_gts_store_validate_missing_required_field() { #[test] fn test_gts_store_schema_with_properties_only() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1553,7 +1585,7 @@ fn test_gts_store_schema_with_properties_only() { #[test] fn test_gts_store_query_no_results() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let result = store.query("gts.nonexistent.*", 10); assert_eq!(result.count, 0); assert!(result.results.is_empty()); @@ -1561,7 +1593,7 @@ fn test_gts_store_query_no_results() { #[test] fn test_gts_store_query_with_zero_limit() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1579,7 +1611,7 @@ fn test_gts_store_query_with_zero_limit() { #[test] fn test_gts_store_cast_same_version() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1618,12 +1650,16 @@ fn test_gts_store_cast_same_version() { "gts.vendor.package.namespace.type.v1.0", "gts.vendor.package.namespace.type.v1.0~", ); - assert!(result.is_ok() || result.is_err()); + let cast = result.expect("casting to the same version should succeed"); + assert!( + cast.casted_entity.is_some(), + "a same-version cast must still produce a casted entity" + ); } #[test] fn test_gts_store_multiple_entities_same_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1667,7 +1703,7 @@ fn test_gts_store_multiple_entities_same_schema() { #[test] fn test_gts_store_get_schema_content_for_entity() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1694,7 +1730,7 @@ fn test_gts_store_get_schema_content_for_entity() { #[test] fn test_gts_store_compatibility_with_removed_properties() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema_v1 = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1735,7 +1771,7 @@ fn test_gts_store_compatibility_with_removed_properties() { #[test] fn test_gts_store_build_schema_graph_single_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1756,7 +1792,7 @@ fn test_gts_store_build_schema_graph_single_schema() { #[test] fn test_gts_store_register_schema_without_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$schema": "http://json-schema.org/draft-07/schema#", @@ -1769,7 +1805,7 @@ fn test_gts_store_register_schema_without_id() { #[test] fn test_gts_store_validate_with_unresolvable_ref() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.vendor.package.namespace.type.v1.0~", @@ -1804,8 +1840,13 @@ fn test_gts_store_validate_with_unresolvable_ref() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - // Should handle unresolvable refs gracefully - assert!(result.is_ok() || result.is_err()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") + ); } #[test] @@ -1826,112 +1867,9 @@ fn test_gts_store_query_result_serialization_with_error() { assert_eq!(json.get("count").expect("test").as_u64().expect("test"), 0); } -#[test] -fn test_gts_store_resolve_schema_refs_with_merge() { - let mut store = GtsStore::new(None); - - // Register base schema - let base_schema = json!({ - "$id": "gts://gts.vendor.package.namespace.base.v1.0~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "id": {"type": "string"} - } - }); - - // Register schema with $ref and additional properties - let schema = json!({ - "$id": "gts://gts.vendor.package.namespace.type.v1.0~", - "$schema": "http://json-schema.org/draft-07/schema#", - "allOf": [ - { - "$ref": "gts://gts.vendor.package.namespace.base.v1.0~", - "properties": { - "name": {"type": "string"} - } - } - ] - }); - - store - .register_schema("gts.vendor.package.namespace.base.v1.0~", &base_schema) - .expect("test"); - store - .register_schema("gts.vendor.package.namespace.type.v1.0~", &schema) - .expect("test"); - - let cfg = GtsConfig::default(); - let content = json!({ - "id": "gts.vendor.package.namespace.type.v1.0", - "name": "test" - }); - - let entity = GtsEntity::new( - None, - None, - &content, - Some(&cfg), - None, - false, - String::new(), - None, - Some("gts.vendor.package.namespace.type.v1.0~".to_owned()), - ); - - store.register(entity).expect("test"); - - let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - assert!(result.is_ok() || result.is_err()); -} - -#[test] -fn test_gts_store_resolve_schema_refs_with_unresolvable_and_properties() { - let mut store = GtsStore::new(None); - - // Schema with unresolvable $ref but with other properties - let schema = json!({ - "$id": "gts://gts.vendor.package.namespace.type.v1.0~", - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "data": { - "$ref": "gts://gts.vendor.package.namespace.nonexistent.v1.0~", - "type": "object" - } - } - }); - - store - .register_schema("gts.vendor.package.namespace.type.v1.0~", &schema) - .expect("test"); - - let cfg = GtsConfig::default(); - let content = json!({ - "id": "gts.vendor.package.namespace.type.v1.0", - "data": {} - }); - - let entity = GtsEntity::new( - None, - None, - &content, - Some(&cfg), - None, - false, - String::new(), - None, - Some("gts.vendor.package.namespace.type.v1.0~".to_owned()), - ); - - store.register(entity).expect("test"); - - let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - assert!(result.is_ok() || result.is_err()); -} - #[test] fn test_gts_store_cast_from_schema_entity() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register two schemas let schema_v1 = json!({ @@ -1966,12 +1904,19 @@ fn test_gts_store_cast_from_schema_entity() { "gts.vendor.package.namespace.type.v1.1~", ); - assert!(result.is_ok() || result.is_err()); + let err = result.expect_err("casting from a schema id (not an instance) must be rejected"); + assert!( + matches!( + err, + StoreError::InvalidEntity(ref m) if m.contains("is a schema, not an instance") + ), + "expected InvalidEntity for a schema-as-source cast, got: {err:?}" + ); } #[test] fn test_gts_store_build_schema_graph_with_type_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register schema let schema = json!({ @@ -2018,7 +1963,7 @@ fn test_gts_store_build_schema_graph_with_type_id() { #[test] fn test_gts_store_query_with_filter_brackets() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Add entities with different properties let cfg = GtsConfig::default(); @@ -2051,7 +1996,7 @@ fn test_gts_store_query_with_filter_brackets() { #[test] fn test_gts_store_query_with_wildcard_filter() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); for i in 0..3 { @@ -2116,7 +2061,7 @@ fn test_gts_store_query_with_wildcard_filter() { #[test] fn test_gts_store_query_invalid_wildcard_pattern() { - let store = GtsStore::new(None); + let store = GtsStore::new(); // Query with invalid wildcard pattern (doesn't end with .* or ~*) let result = store.query("gts.vendor*", 10); @@ -2126,7 +2071,7 @@ fn test_gts_store_query_invalid_wildcard_pattern() { #[test] fn test_gts_store_query_invalid_gts_id() { - let store = GtsStore::new(None); + let store = GtsStore::new(); // Query with invalid GTS ID let result = store.query("invalid-id", 10); @@ -2135,7 +2080,7 @@ fn test_gts_store_query_invalid_gts_id() { #[test] fn test_gts_store_query_gts_id_no_segments() { - let store = GtsStore::new(None); + let store = GtsStore::new(); // This should create an error for GTS ID with no valid segments let result = store.query("gts", 10); @@ -2144,16 +2089,20 @@ fn test_gts_store_query_gts_id_no_segments() { #[test] fn test_gts_store_validate_instance_invalid_gts_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Try to validate with invalid GTS ID let result = store.validate_instance("invalid-id"); - assert!(result.is_err()); + let err = result.expect_err("validating an unregistered id must fail"); + assert!( + matches!(err, StoreError::InstanceNotFound(ref m) if m.contains("invalid-id")), + "expected InstanceNotFound for an unregistered id, got: {err:?}" + ); } #[test] fn test_gts_store_validate_instance_invalid_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register entity with schema that has invalid JSON Schema let schema = json!({ @@ -2250,7 +2199,7 @@ fn test_gts_store_with_reader() { } let reader = MockGtsReader::new(entities); - let store = GtsStore::new(Some(Box::new(reader))); + let store = GtsStore::with_reader(Box::new(reader)); // Store should be populated from reader assert_eq!(store.items().count(), 3); @@ -2279,7 +2228,7 @@ fn test_gts_store_get_from_reader() { ); let reader = MockGtsReader::new(vec![entity]); - let mut store = GtsStore::new(Some(Box::new(reader))); + let mut store = GtsStore::with_reader(Box::new(reader)); // Get entity that's not in cache but available from reader let result = store.get("gts.vendor.package.namespace.item.v1.0"); @@ -2306,7 +2255,7 @@ fn test_gts_store_reader_without_gts_id() { ); let reader = MockGtsReader::new(vec![entity]); - let store = GtsStore::new(Some(Box::new(reader))); + let store = GtsStore::with_reader(Box::new(reader)); // Entity without gts_id should not be added to store assert_eq!(store.items().count(), 0); @@ -2318,7 +2267,7 @@ fn test_validate_schema_refs_valid_gts_uri() { let schema = json!({ "$ref": "gts://gts.vendor.package.namespace.type.v1.0~" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_ok()); } @@ -2328,7 +2277,7 @@ fn test_validate_schema_refs_valid_local_ref() { let schema = json!({ "$ref": "#/definitions/MyType" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_ok()); } @@ -2338,7 +2287,7 @@ fn test_validate_schema_refs_invalid_bare_gts_id() { let schema = json!({ "$ref": "gts.vendor.package.namespace.type.v1.0~" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("must be a local ref")); @@ -2351,7 +2300,7 @@ fn test_validate_schema_refs_invalid_http_uri() { let schema = json!({ "$ref": "https://example.com/schema.json" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("must be a local ref")); @@ -2363,7 +2312,7 @@ fn test_validate_schema_refs_invalid_gts_id_in_uri() { let schema = json!({ "$ref": "gts://invalid-gts-id" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("must reference a GTS type id")); @@ -2382,7 +2331,7 @@ fn test_validate_schema_refs_nested() { } } }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("properties.order.$ref")); @@ -2397,7 +2346,7 @@ fn test_validate_schema_refs_in_array() { {"$ref": "not-valid-ref"} ] }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("allOf[1].$ref")); @@ -2405,7 +2354,7 @@ fn test_validate_schema_refs_in_array() { #[test] fn test_validate_schema_integration() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Schema with invalid $ref should fail validation let schema = json!({ @@ -2420,70 +2369,12 @@ fn test_validate_schema_integration() { assert!(result.is_ok()); // Registration succeeds // But validation should fail - let validation_result = store.validate_schema("gts.vendor.package.namespace.type.v1.0~"); + let validation_result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"); assert!(validation_result.is_err()); let err = validation_result.unwrap_err().to_string(); assert!(err.contains("must be a local ref") || err.contains("gts://")); } -#[test] -fn test_resolve_schema_refs_with_gts_uri_prefix() { - let mut store = GtsStore::new(None); - - // Register base schema - let base_schema = json!({ - "$id": "gts://gts.vendor.package.namespace.base.v1.0~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "id": {"type": "string"} - } - }); - - // Register schema that uses gts:// prefix in $ref - let schema = json!({ - "$id": "gts://gts.vendor.package.namespace.type.v1.0~", - "$schema": "http://json-schema.org/draft-07/schema#", - "allOf": [ - {"$ref": "gts://gts.vendor.package.namespace.base.v1.0~"} - ] - }); - - store - .register_schema("gts.vendor.package.namespace.base.v1.0~", &base_schema) - .expect("test"); - store - .register_schema("gts.vendor.package.namespace.type.v1.0~", &schema) - .expect("test"); - - // Create and register an instance - let cfg = GtsConfig::default(); - let content = json!({ - "id": "gts.vendor.package.namespace.type.v1.0~instance.v1.0", - "type": "gts.vendor.package.namespace.type.v1.0~" - }); - - let entity = GtsEntity::new( - None, - None, - &content, - Some(&cfg), - None, - false, - String::new(), - None, - None, - ); - - store.register(entity).expect("test"); - - // Validation should work - the gts:// prefix should be stripped for resolution - let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0~instance.v1.0"); - // The validation may fail for other reasons, but it should not fail due to $ref resolution - // Just verify it doesn't panic - let _ = result; -} - // ============================================================================= // Tests for $ref validation (commit 00d298c) // ============================================================================= @@ -2494,7 +2385,7 @@ fn test_validate_schema_refs_rejects_external_ref_without_gts_prefix() { let schema = json!({ "$ref": "http://example.com/schema.json" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( @@ -2509,7 +2400,7 @@ fn test_validate_schema_refs_rejects_malformed_gts_id_in_ref() { let schema = json!({ "$ref": "gts://invalid-gts-id" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( @@ -2524,7 +2415,7 @@ fn test_validate_schema_refs_accepts_valid_gts_ref() { let schema = json!({ "$ref": "gts://gts.vendor.package.namespace.type.v1.0~" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_ok(), "Valid gts:// ref should be accepted"); } @@ -2534,7 +2425,7 @@ fn test_validate_schema_refs_accepts_local_json_pointer() { let schema = json!({ "$ref": "#/definitions/Base" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_ok(), "Local JSON Pointer ref should be accepted"); } @@ -2544,7 +2435,7 @@ fn test_validate_schema_refs_accepts_root_json_pointer() { let schema = json!({ "$ref": "#" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_ok(), "Root JSON Pointer ref should be accepted"); } @@ -2554,7 +2445,7 @@ fn test_validate_schema_refs_rejects_gts_colon_without_slashes() { let schema = json!({ "$ref": "gts:gts.vendor.package.namespace.type.v1.0~" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( @@ -2581,7 +2472,7 @@ fn test_validate_schema_refs_deeply_nested_invalid_ref() { } } }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( @@ -2600,7 +2491,7 @@ fn test_validate_schema_refs_mixed_valid_and_invalid() { {"$ref": "invalid-ref"} ] }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err(), "Should fail when any ref is invalid"); let err = result.unwrap_err().to_string(); assert!( @@ -2615,7 +2506,7 @@ fn test_validate_schema_refs_empty_string() { let schema = json!({ "$ref": "" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( @@ -2630,7 +2521,7 @@ fn test_validate_schema_refs_gts_prefix_but_empty_id() { let schema = json!({ "$ref": "gts://" }); - let result = GtsStore::validate_schema_refs(&schema, ""); + let result = GtsStore::validate_ref_uris(&schema); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( @@ -2642,14 +2533,13 @@ fn test_validate_schema_refs_gts_prefix_but_empty_id() { #[test] fn test_validate_schema_x_gts_refs_non_schema_id() { // Test error when gts_id doesn't end with '~' - let mut store = GtsStore::new(None); - let result = store.validate_schema_x_gts_refs("gts.vendor.package.namespace.type.v1.0"); + let mut store = GtsStore::new(); + let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0"); assert!(result.is_err()); match result { Err(StoreError::SchemaNotFound(msg)) => { - assert!(msg.contains("is not a schema")); - assert!(msg.contains("must end with '~'")); + assert!(msg.contains("Invalid type id")); } _ => panic!("Expected SchemaNotFound error"), } @@ -2658,8 +2548,8 @@ fn test_validate_schema_x_gts_refs_non_schema_id() { #[test] fn test_validate_schema_x_gts_refs_schema_not_found() { // Test error when schema doesn't exist in store - let mut store = GtsStore::new(None); - let result = store.validate_schema_x_gts_refs("gts.vendor.package.namespace.type.v1.0~"); + let mut store = GtsStore::new(); + let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); match result { @@ -2673,7 +2563,7 @@ fn test_validate_schema_x_gts_refs_schema_not_found() { #[test] fn test_validate_schema_x_gts_refs_entity_not_schema() { // Test error when entity exists but is_schema is false - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); // Create an instance with an ID that ends with '~' but is_schema=false @@ -2697,7 +2587,7 @@ fn test_validate_schema_x_gts_refs_entity_not_schema() { store.register(entity).expect("test"); - let result = store.validate_schema_x_gts_refs("gts.vendor.package.namespace.type.v1.0~"); + let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); match result { Err(StoreError::SchemaNotFound(msg)) => { @@ -2710,7 +2600,6 @@ fn test_validate_schema_x_gts_refs_entity_not_schema() { #[test] fn test_validate_schema_x_gts_refs_validation_error() { // Test error when x-gts-ref validation fails - let mut store = GtsStore::new(None); // Create a schema with invalid x-gts-ref let schema_content = json!({ @@ -2725,11 +2614,7 @@ fn test_validate_schema_x_gts_refs_validation_error() { } }); - store - .register_schema("gts.vendor.package.namespace.type.v1.0~", &schema_content) - .expect("test"); - - let result = store.validate_schema_x_gts_refs("gts.vendor.package.namespace.type.v1.0~"); + let result = GtsStore::validate_schema_x_gts_refs(&schema_content); assert!(result.is_err()); match result { Err(StoreError::ValidationError(msg)) => { @@ -2742,14 +2627,13 @@ fn test_validate_schema_x_gts_refs_validation_error() { #[test] fn test_validate_schema_non_schema_id() { // Test lines 443-445: ID doesn't end with '~' - let mut store = GtsStore::new(None); - let result = store.validate_schema("gts.vendor.package.namespace.type.v1.0"); + let mut store = GtsStore::new(); + let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0"); assert!(result.is_err()); match result { Err(StoreError::SchemaNotFound(msg)) => { - assert!(msg.contains("is not a schema")); - assert!(msg.contains("must end with '~'")); + assert!(msg.contains("Invalid type id")); } _ => panic!("Expected SchemaNotFound error"), } @@ -2758,7 +2642,7 @@ fn test_validate_schema_non_schema_id() { #[test] fn test_validate_schema_entity_not_schema() { // Test lines 453-455: Entity exists but is_schema is false - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); let content = json!({ @@ -2781,7 +2665,7 @@ fn test_validate_schema_entity_not_schema() { store.register(entity).expect("test"); - let result = store.validate_schema("gts.vendor.package.namespace.type.v1.0~"); + let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); match result { Err(StoreError::SchemaNotFound(msg)) => { @@ -2796,7 +2680,7 @@ fn test_validate_schema_content_not_object() { // Test error case when schema content is not an object // When content is non-object (array), GtsEntity.has_schema_field() returns false // so is_schema becomes false, triggering the error on line 453-455 instead of 460-462 - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Create schema with non-object content (an array) let schema_content = json!(["not", "an", "object"]); @@ -2805,7 +2689,7 @@ fn test_validate_schema_content_not_object() { .register_schema("gts.vendor.package.namespace.type.v1.0~", &schema_content) .expect("test"); - let result = store.validate_schema("gts.vendor.package.namespace.type.v1.0~"); + let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); match result { Err(StoreError::SchemaNotFound(msg)) => { @@ -2823,7 +2707,7 @@ fn test_validate_schema_content_not_object() { #[test] fn test_validate_instance_schema_compilation_error() { // Test lines 542-544: Schema compilation error - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); // Create an invalid schema that will fail compilation @@ -2871,7 +2755,7 @@ fn test_validate_instance_schema_compilation_error() { #[test] fn test_validate_instance_validation_failed() { // Test lines 547-549: Instance validation failed - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); // Create a valid schema @@ -2923,7 +2807,7 @@ fn test_validate_instance_validation_failed() { #[test] fn test_validate_instance_x_gts_ref_validation_failed() { // Test lines 556-568: x-gts-ref validation failed - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); // Create a schema with x-gts-ref constraint @@ -2977,7 +2861,7 @@ fn test_validate_instance_x_gts_ref_validation_failed() { #[test] fn test_cast_missing_schema_for_instance() { // Test lines 599-605: Instance exists but has no schema_id - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let cfg = GtsConfig::default(); // Create an instance without a schema_id @@ -3018,10 +2902,10 @@ fn test_cast_missing_schema_for_instance() { assert!(result.is_err()); match result { - Err(StoreError::SchemaForInstanceNotFound(id)) => { - assert_eq!(id, "gts.vendor.package.namespace.type.v1.0"); + Err(StoreError::InvalidEntity(msg)) => { + assert!(msg.contains("gts.vendor.package.namespace.type.v1.0")); } - _ => panic!("Expected SchemaForInstanceNotFound error"), + _ => panic!("Expected InvalidEntity error"), } } @@ -3029,7 +2913,7 @@ fn test_cast_missing_schema_for_instance() { #[test] fn test_op12_single_segment_schema_always_valid() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let schema = json!({ "$id": "gts://gts.x.test.base.user.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -3044,7 +2928,7 @@ fn test_op12_single_segment_schema_always_valid() { .register_schema("gts.x.test.base.user.v1~", &schema) .expect("register"); - let result = store.validate_schema("gts.x.test.base.user.v1~"); + let result = store.validate_schema_refs("gts.x.test.base.user.v1~"); assert!( result.is_ok(), "Single-segment schema should always pass chain validation" @@ -3053,7 +2937,7 @@ fn test_op12_single_segment_schema_always_valid() { #[test] fn test_op12_derived_tightens_constraints_ok() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register base schema let base = json!({ @@ -3090,7 +2974,7 @@ fn test_op12_derived_tightens_constraints_ok() { .register_schema("gts.x.test12.base.user.v1~x.test12._.premium.v1~", &derived) .expect("register derived"); - let result = store.validate_schema("gts.x.test12.base.user.v1~x.test12._.premium.v1~"); + let result = store.validate_schema_refs("gts.x.test12.base.user.v1~x.test12._.premium.v1~"); assert!( result.is_ok(), "Derived that tightens constraints should pass: {result:?}" @@ -3099,7 +2983,7 @@ fn test_op12_derived_tightens_constraints_ok() { #[test] fn test_op12_derived_adds_property_ok() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.base.user.v1~", @@ -3135,7 +3019,7 @@ fn test_op12_derived_adds_property_ok() { ) .expect("register derived"); - let result = store.validate_schema("gts.x.test12.base.user.v1~x.test12._.extended.v1~"); + let result = store.validate_schema_refs("gts.x.test12.base.user.v1~x.test12._.extended.v1~"); assert!( result.is_ok(), "Adding property to open base should pass: {result:?}" @@ -3144,7 +3028,7 @@ fn test_op12_derived_adds_property_ok() { #[test] fn test_op12_additional_properties_false_violation() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.closed.account.v1~", @@ -3192,7 +3076,7 @@ fn test_op12_additional_properties_false_violation() { #[test] fn test_op12_loosened_max_length_fails() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.str.field.v1~", @@ -3230,7 +3114,7 @@ fn test_op12_loosened_max_length_fails() { #[test] fn test_op12_loosened_maximum_fails() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.num.field.v1~", @@ -3268,7 +3152,7 @@ fn test_op12_loosened_maximum_fails() { #[test] fn test_op12_enum_expansion_fails() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.enum.status.v1~", @@ -3309,7 +3193,7 @@ fn test_op12_enum_expansion_fails() { #[test] fn test_op12_3level_progressive_tightening_ok() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.cascade.msg.v1~", @@ -3380,7 +3264,7 @@ fn test_op12_3level_progressive_tightening_ok() { #[test] fn test_op12_3level_l3_violates_l2() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.hier.base.v1~", @@ -3445,7 +3329,7 @@ fn test_op12_3level_l3_violates_l2() { #[test] fn test_op12_property_disabled_fails() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test12.order.base.v1~", @@ -3492,8 +3376,8 @@ fn test_op12_property_disabled_fails() { } #[test] -fn test_op12_derived_loosens_additional_properties_to_true() { - let mut store = GtsStore::new(None); +fn test_op12_direct_derived_loosens_additional_properties_to_true() { + let mut store = GtsStore::new(); // Base schema with additionalProperties: false let base = json!({ @@ -3509,14 +3393,14 @@ fn test_op12_derived_loosens_additional_properties_to_true() { .register_schema("gts.x.test.addl.closed.v1~", &base) .expect("register base"); - // Derived schema that sets additionalProperties: true (loosening) + // Direct derived schema that sets additionalProperties: true (loosening) let derived = json!({ "$id": "gts://gts.x.test.addl.closed.v1~x.test._.open.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "allOf": [ - {"$ref": "gts://gts.x.test.addl.closed.v1~"} - ], + "properties": { + "id": {"type": "string"} + }, "additionalProperties": true }); store @@ -3530,6 +3414,50 @@ fn test_op12_derived_loosens_additional_properties_to_true() { ); } +#[test] +fn test_op12_allof_overlay_additional_properties_true_stays_closed() { + let mut store = GtsStore::new(); + + let base = json!({ + "$id": "gts://gts.x.test.addl.closed3.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "id": {"type": "string"} + }, + "additionalProperties": false + }); + store + .register_schema("gts.x.test.addl.closed3.v1~", &base) + .expect("register base"); + + let derived = json!({ + "$id": "gts://gts.x.test.addl.closed3.v1~x.test._.overlay.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$ref": "gts://gts.x.test.addl.closed3.v1~"}, + { + "type": "object", + "properties": { + "id": {"type": "string"} + }, + "additionalProperties": true + } + ] + }); + store + .register_schema("gts.x.test.addl.closed3.v1~x.test._.overlay.v1~", &derived) + .expect("register derived"); + + let result = store.validate_schema_chain("gts.x.test.addl.closed3.v1~x.test._.overlay.v1~"); + assert!( + result.is_ok(), + "additionalProperties: true in an allOf overlay does not loosen \ + a closed base branch. Got: {result:?}" + ); +} + #[test] fn test_op12_derived_omits_additional_properties_inherits_closedness() { // Per JSON Schema, `additionalProperties` at a level with no own @@ -3544,7 +3472,7 @@ fn test_op12_derived_omits_additional_properties_inherits_closedness() { // accept this shape — anything stricter is an artificial constraint // imposed by literal structural comparison, not by JSON Schema // semantics. See `docs/bugs/op12-derived-additional-properties.md`. - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test.addl.closed2.v1~", @@ -3584,7 +3512,7 @@ fn test_op12_derived_omits_additional_properties_inherits_closedness() { #[test] fn test_op12_derived_omits_const() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test.const.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -3619,7 +3547,7 @@ fn test_op12_derived_omits_const() { #[test] fn test_op12_derived_omits_pattern() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test.pattern.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -3654,7 +3582,7 @@ fn test_op12_derived_omits_pattern() { #[test] fn test_op12_derived_omits_enum() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test.enum.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -3689,7 +3617,7 @@ fn test_op12_derived_omits_enum() { #[test] fn test_op12_derived_omits_max_length() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test.maxlen.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", @@ -3728,7 +3656,7 @@ fn test_op12_derived_omits_max_length() { #[test] fn test_op13_traits_all_resolved_passes() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test13.tr.base.v1~", @@ -3752,22 +3680,19 @@ fn test_op13_traits_all_resolved_passes() { "$id": "gts://gts.x.test13.tr.base.v1~x.test13._.leaf.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.test._.orders.v1", + "retention": "P90D" + }, "allOf": [ - {"$ref": "gts://gts.x.test13.tr.base.v1~"}, - { - "type": "object", - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.test._.orders.v1", - "retention": "P90D" - } - } + {"$ref": "gts://gts.x.test13.tr.base.v1~"} ] }); store .register_schema("gts.x.test13.tr.base.v1~x.test13._.leaf.v1~", &derived) .expect("register derived"); - let result = store.validate_schema_traits("gts.x.test13.tr.base.v1~x.test13._.leaf.v1~"); + let result = store.validate_schema("gts.x.test13.tr.base.v1~x.test13._.leaf.v1~"); assert!( result.is_ok(), "All traits resolved should pass: {result:?}" @@ -3776,7 +3701,7 @@ fn test_op13_traits_all_resolved_passes() { #[test] fn test_op13_traits_defaults_fill_passes() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test13.dfl.base.v1~", @@ -3808,13 +3733,13 @@ fn test_op13_traits_defaults_fill_passes() { .register_schema("gts.x.test13.dfl.base.v1~x.test13._.leaf.v1~", &derived) .expect("register derived"); - let result = store.validate_schema_traits("gts.x.test13.dfl.base.v1~x.test13._.leaf.v1~"); + let result = store.validate_schema("gts.x.test13.dfl.base.v1~x.test13._.leaf.v1~"); assert!(result.is_ok(), "Defaults should fill traits: {result:?}"); } #[test] fn test_op13_traits_missing_required_fails() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test13.mis.base.v1~", @@ -3838,132 +3763,62 @@ fn test_op13_traits_missing_required_fails() { "$id": "gts://gts.x.test13.mis.base.v1~x.test13._.leaf.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", + "x-gts-traits": {"retention": "P90D"}, "allOf": [ - {"$ref": "gts://gts.x.test13.mis.base.v1~"}, - { - "type": "object", - "x-gts-traits": {"retention": "P90D"} - } + {"$ref": "gts://gts.x.test13.mis.base.v1~"} ] }); store .register_schema("gts.x.test13.mis.base.v1~x.test13._.leaf.v1~", &derived) .expect("register derived"); - let result = store.validate_schema_traits("gts.x.test13.mis.base.v1~x.test13._.leaf.v1~"); + let result = store.validate_schema("gts.x.test13.mis.base.v1~x.test13._.leaf.v1~"); assert!(result.is_err(), "Missing topicRef should fail"); } #[test] -fn test_op13_entity_traits_abstract_base_skips_completeness() { - // gts-spec §9.7.5 / §9.11.4 (ADR-0003): a type marked `x-gts-abstract: true` - // is exempt from the OP#13 entity-level completeness check. It may declare an - // `x-gts-traits-schema` without resolving any `x-gts-traits` values — concrete - // descendants are expected to close the required traits. - let mut store = GtsStore::new(None); +fn test_op13_traits_wrong_type_fails() { + let mut store = GtsStore::new(); let base = json!({ - "$id": "gts://gts.x.test13.abs.base.v1~", + "$id": "gts://gts.x.test13.wt.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "x-gts-abstract": true, "x-gts-traits-schema": { "type": "object", - "additionalProperties": false, "properties": { - "topicRef": {"type": "string"} + "maxRetries": {"type": "integer", "minimum": 0, "default": 3} } }, "properties": {"id": {"type": "string"}} }); store - .register_schema("gts.x.test13.abs.base.v1~", &base) - .expect("register abstract base"); - - let result = store.validate_entity_traits("gts.x.test13.abs.base.v1~"); - assert!( - result.is_ok(), - "Abstract base must be exempt from the OP#13 completeness check: {result:?}" - ); -} - -#[test] -fn test_op13_entity_traits_non_abstract_base_without_values_fails() { - // The flip side of the abstract exemption: a non-abstract type that declares - // a trait schema but supplies no `x-gts-traits` values anywhere in its chain - // is incomplete and must still fail OP#13. - let mut store = GtsStore::new(None); + .register_schema("gts.x.test13.wt.base.v1~", &base) + .expect("register base"); - let base = json!({ - "$id": "gts://gts.x.test13.conc.base.v1~", + let derived = json!({ + "$id": "gts://gts.x.test13.wt.base.v1~x.test13._.leaf.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "x-gts-traits-schema": { - "type": "object", - "additionalProperties": false, - "properties": { - "topicRef": {"type": "string"} - } - }, - "properties": {"id": {"type": "string"}} + "x-gts-traits": {"maxRetries": "not_a_number"}, + "allOf": [ + {"$ref": "gts://gts.x.test13.wt.base.v1~"} + ] }); store - .register_schema("gts.x.test13.conc.base.v1~", &base) - .expect("register concrete base"); + .register_schema("gts.x.test13.wt.base.v1~x.test13._.leaf.v1~", &derived) + .expect("register derived"); - let result = store.validate_entity_traits("gts.x.test13.conc.base.v1~"); - assert!( - result.is_err(), - "Non-abstract base with no trait values must fail the OP#13 completeness check" - ); + let result = store.validate_schema("gts.x.test13.wt.base.v1~x.test13._.leaf.v1~"); + assert!(result.is_err(), "Wrong type should fail"); } #[test] -fn test_op13_traits_wrong_type_fails() { - let mut store = GtsStore::new(None); +fn test_op13_traits_no_traits_schema_passes() { + let mut store = GtsStore::new(); let base = json!({ - "$id": "gts://gts.x.test13.wt.base.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "x-gts-traits-schema": { - "type": "object", - "properties": { - "maxRetries": {"type": "integer", "minimum": 0, "default": 3} - } - }, - "properties": {"id": {"type": "string"}} - }); - store - .register_schema("gts.x.test13.wt.base.v1~", &base) - .expect("register base"); - - let derived = json!({ - "$id": "gts://gts.x.test13.wt.base.v1~x.test13._.leaf.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "allOf": [ - {"$ref": "gts://gts.x.test13.wt.base.v1~"}, - { - "type": "object", - "x-gts-traits": {"maxRetries": "not_a_number"} - } - ] - }); - store - .register_schema("gts.x.test13.wt.base.v1~x.test13._.leaf.v1~", &derived) - .expect("register derived"); - - let result = store.validate_schema_traits("gts.x.test13.wt.base.v1~x.test13._.leaf.v1~"); - assert!(result.is_err(), "Wrong type should fail"); -} - -#[test] -fn test_op13_traits_no_traits_schema_passes() { - let mut store = GtsStore::new(None); - - let base = json!({ - "$id": "gts://gts.x.test13.nt.base.v1~", + "$id": "gts://gts.x.test13.nt.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}} @@ -3985,62 +3840,16 @@ fn test_op13_traits_no_traits_schema_passes() { .register_schema("gts.x.test13.nt.base.v1~x.test13._.leaf.v1~", &derived) .expect("register derived"); - let result = store.validate_schema_traits("gts.x.test13.nt.base.v1~x.test13._.leaf.v1~"); + let result = store.validate_schema("gts.x.test13.nt.base.v1~x.test13._.leaf.v1~"); assert!( result.is_ok(), "No traits schema means nothing to validate: {result:?}" ); } -#[test] -fn test_store_resolve_schema_refs_empty_schema() { - let store = GtsStore::new(None); - let empty_schema = json!({}); - let resolved = store.resolve_schema_refs(&empty_schema); - assert_eq!(resolved, empty_schema); -} - -#[test] -fn test_store_resolve_schema_refs_null_value() { - let store = GtsStore::new(None); - let null_schema = Value::Null; - let resolved = store.resolve_schema_refs(&null_schema); - assert_eq!(resolved, null_schema); -} - -#[test] -fn test_store_resolve_schema_refs_array_value() { - let store = GtsStore::new(None); - let array_schema = json!([1, 2, 3]); - let resolved = store.resolve_schema_refs(&array_schema); - assert_eq!(resolved, array_schema); -} - -#[test] -fn test_store_resolve_schema_refs_primitive_value() { - let store = GtsStore::new(None); - let string_schema = json!("test"); - let resolved = store.resolve_schema_refs(&string_schema); - assert_eq!(resolved, string_schema); -} - -#[test] -fn test_store_resolve_schema_refs_nested_objects() { - let store = GtsStore::new(None); - let nested = json!({ - "outer": { - "inner": { - "deep": "value" - } - } - }); - let resolved = store.resolve_schema_refs(&nested); - assert_eq!(resolved, nested); -} - #[test] fn test_store_items_iterator_size() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Initially empty assert_eq!(store.items().count(), 0); @@ -4062,7 +3871,7 @@ fn test_store_items_iterator_size() { #[test] fn test_store_query_empty_expr() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let result = store.query("", 10); // Empty query should return error @@ -4071,7 +3880,7 @@ fn test_store_query_empty_expr() { #[test] fn test_store_query_with_very_large_limit() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Add a schema store @@ -4091,7 +3900,7 @@ fn test_store_query_with_very_large_limit() { #[test] fn test_store_register_schema_validates_type_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Valid schema ID ending with ~ let type_id = "gts.test.package.namespace.minimal.v1~"; @@ -4118,7 +3927,7 @@ fn test_store_register_schema_validates_type_id() { #[test] fn test_store_build_schema_graph_with_nonexistent_id() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Use a valid GTS ID format but one that doesn't exist let graph = store.build_schema_graph("gts.nonexistent.schema.v1~"); @@ -4128,9 +3937,9 @@ fn test_store_build_schema_graph_with_nonexistent_id() { #[test] fn test_store_error_debug_display() { - let err = StoreError::EntityNotFound("test_id".to_owned()); + let err = StoreError::InstanceNotFound("test_id".to_owned()); let debug_str = format!("{err:?}"); - assert!(debug_str.contains("EntityNotFound")); + assert!(debug_str.contains("InstanceNotFound")); let display_str = format!("{err}"); assert!(display_str.contains("test_id")); @@ -4139,22 +3948,32 @@ fn test_store_error_debug_display() { #[test] fn test_store_error_variants() { // Test various error types exist and can be formatted - let err1 = StoreError::InvalidSchemaId; - assert!(format!("{err1}").contains('~')); + let err1 = StoreError::InvalidTypeId(GtsIdError::new("bad", "not a type id")); + assert!(format!("{err1}").contains("Invalid GTS type id")); - let err2 = StoreError::InvalidEntity; + let err2 = StoreError::InvalidEntity("bad".to_owned()); assert!(format!("{err2:?}").contains("InvalidEntity")); let err3 = StoreError::ValidationError("test error".to_owned()); assert!(format!("{err3}").contains("test error")); + + let err4 = StoreError::CircularRef; + assert_eq!(err4.to_string(), "Circular $ref detected"); + + let err5 = StoreError::UnresolvedRefs(vec!["a".to_owned(), "b".to_owned()]); + assert!( + err5.to_string().contains("a, b"), + "UnresolvedRefs must render the joined ref list, got: {err5}" + ); } #[test] fn test_store_get_schema_content_returns_copy() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let type_id = "gts.test.package.namespace.copy.v1~"; let schema = json!({ "$id": format!("gts://{type_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"field": {"type": "string"}} }); @@ -4170,7 +3989,7 @@ fn test_store_get_schema_content_returns_copy() { #[test] fn test_op13_traits_ref_based_trait_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register standalone reusable trait schema let retention_trait = json!({ @@ -4220,22 +4039,19 @@ fn test_op13_traits_ref_based_trait_schema() { "$id": "gts://gts.x.test13.ref.base.v1~x.test13._.leaf.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.test._.orders.v1", + "retention": "P90D" + }, "allOf": [ - {"$ref": "gts://gts.x.test13.ref.base.v1~"}, - { - "type": "object", - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.test._.orders.v1", - "retention": "P90D" - } - } + {"$ref": "gts://gts.x.test13.ref.base.v1~"} ] }); store .register_schema("gts.x.test13.ref.base.v1~x.test13._.leaf.v1~", &derived) .expect("register derived"); - let result = store.validate_schema_traits("gts.x.test13.ref.base.v1~x.test13._.leaf.v1~"); + let result = store.validate_schema("gts.x.test13.ref.base.v1~x.test13._.leaf.v1~"); assert!( result.is_ok(), "$ref trait schemas should resolve and validate: {result:?}" @@ -4244,7 +4060,7 @@ fn test_op13_traits_ref_based_trait_schema() { #[test] fn test_op13_traits_ref_to_nonexistent_schema() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Base with trait schema that $refs a schema not in the store let base = json!({ @@ -4267,12 +4083,9 @@ fn test_op13_traits_ref_to_nonexistent_schema() { "$id": "gts://gts.x.test13.badref.base.v1~x.test13._.leaf.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", + "x-gts-traits": {"foo": "bar"}, "allOf": [ - {"$ref": "gts://gts.x.test13.badref.base.v1~"}, - { - "type": "object", - "x-gts-traits": {"foo": "bar"} - } + {"$ref": "gts://gts.x.test13.badref.base.v1~"} ] }); store @@ -4280,90 +4093,13 @@ fn test_op13_traits_ref_to_nonexistent_schema() { .expect("register derived"); // Unresolvable $ref causes validation to fail (jsonschema can't resolve it) - let result = store.validate_schema_traits("gts.x.test13.badref.base.v1~x.test13._.leaf.v1~"); + let result = store.validate_schema("gts.x.test13.badref.base.v1~x.test13._.leaf.v1~"); assert!( result.is_err(), "Unresolvable $ref should cause validation error" ); } -#[test] -fn test_op13_circular_ref_does_not_hang() { - let mut store = GtsStore::new(None); - - // Schema A refs schema B, schema B refs schema A — circular - let schema_a = json!({ - "$id": "gts://gts.x.test13.circ.a.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "x-gts-traits-schema": { - "type": "object", - "allOf": [ - {"$ref": "gts://gts.x.test13.circ.b.v1~"} - ] - }, - "properties": {"id": {"type": "string"}} - }); - let schema_b = json!({ - "$id": "gts://gts.x.test13.circ.b.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "allOf": [ - {"$ref": "gts://gts.x.test13.circ.a.v1~"} - ], - "properties": {"name": {"type": "string"}} - }); - store - .register_schema("gts.x.test13.circ.a.v1~", &schema_a) - .expect("register A"); - store - .register_schema("gts.x.test13.circ.b.v1~", &schema_b) - .expect("register B"); - - // resolve_schema_refs must not infinite-loop on circular refs - let resolved = store.resolve_schema_refs(&schema_a); - // Should terminate and produce a value (circular part is dropped) - assert!(resolved.is_object(), "should produce a valid object"); -} - -#[test] -fn test_resolve_schema_refs_checked_allows_duplicate_ref_in_allof() { - // Redundant manual aggregation (the same $ref appearing more than once - // in an allOf composition along the chain) is allowed. - // resolve_schema_refs_checked uses DFS-path cycle detection, so - // independent duplicate $refs are not flagged as cycles. - let mut store = GtsStore::new(None); - - let trait_schema = json!({ - "$id": "gts://gts.x.test.dup.trait.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "retention": {"type": "string"} - } - }); - store - .register_schema("gts.x.test.dup.trait.v1~", &trait_schema) - .expect("register trait schema"); - - let trait_schema_value = json!({ - "type": "object", - "allOf": [ - {"$ref": "gts://gts.x.test.dup.trait.v1~"}, - {"$ref": "gts://gts.x.test.dup.trait.v1~"} - ] - }); - - let result = store.resolve_schema_refs_checked(&trait_schema_value); - assert!( - result.is_ok(), - "resolve_schema_refs_checked should allow duplicate $ref in allOf, got: {result:?}", - ); - - let resolved = store.resolve_schema_refs(&trait_schema_value); - assert!(resolved.is_object(), "resolve_schema_refs should succeed"); -} - #[test] fn test_op13_redeclared_default_in_mid_allowed() { // With chain aggregation via allOf and RFC 7396 merge for trait values @@ -4371,7 +4107,7 @@ fn test_op13_redeclared_default_in_mid_allowed() { // `default`. It simply doesn't take effect for a property already defined // upstream — the aggregated allOf retains both declarations and the first // matching default wins per JSON Schema. - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test13.chdfl.event.v1~", @@ -4399,33 +4135,1610 @@ fn test_op13_redeclared_default_in_mid_allowed() { "$id": "gts://gts.x.test13.chdfl.event.v1~x.test13._.chdfl_mid.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "allOf": [ - {"$ref": "gts://gts.x.test13.chdfl.event.v1~"}, - { - "type": "object", - "x-gts-traits-schema": { - "type": "object", - "properties": { - "retention": { - "type": "string", - "default": "P90D" - } - } - }, - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.test13._.orders.v1" + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { + "type": "string", + "default": "P90D" } } + }, + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.test13._.orders.v1" + }, + "allOf": [ + {"$ref": "gts://gts.x.test13.chdfl.event.v1~"} ] }); store .register_schema("gts.x.test13.chdfl.event.v1~x.test13._.chdfl_mid.v1~", &mid) .expect("register mid"); - let result = - store.validate_schema_traits("gts.x.test13.chdfl.event.v1~x.test13._.chdfl_mid.v1~"); + let result = store.validate_schema("gts.x.test13.chdfl.event.v1~x.test13._.chdfl_mid.v1~"); assert!( result.is_ok(), "Redeclared default in descendant should be allowed, got: {result:?}" ); } + +#[test] +fn test_effective_traits_walks_id_chain() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.cti.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { "type": "object", "properties": { + "retention": {"type": "string", "default": "P30D"}, + "tier": {"type": "string"} + }}, + "x-gts-traits": {"tier": "standard"} + }), + ) + .unwrap(); + store + .register_schema( + "gts.x.cti.tr.base.v1~x.cti._.leaf.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits": {"tier": "premium"} + }), + ) + .unwrap(); + + let id = "gts.x.cti.tr.base.v1~x.cti._.leaf.v1~"; + let traits = store.effective_traits(id).unwrap(); + assert_eq!( + traits.resolved_trait_schemas.len(), + 1, + "one x-gts-traits-schema in the chain" + ); + assert_eq!( + traits.merged_traits["tier"], "premium", + "leaf value wins (RFC 7396)" + ); + assert_eq!( + traits.values["retention"], "P30D", + "ancestor default is materialized" + ); + assert_eq!( + traits.schema["$schema"], "http://json-schema.org/draft-07/schema#", + "leaf dialect is pinned into the composed trait schema" + ); +} + +/// Assert every `ResolvedType` field against exact expected values. Comparing +/// whole `serde_json::Value`s (order-insensitive) keeps these tests readable: +/// each expectation is the literal document the resolver should emit. +#[allow(clippy::needless_pass_by_value)] // by-value `json!(...)` literals read cleaner at call sites +#[allow(clippy::fn_params_excessive_bools)] // mirrors the struct's flag fields +fn assert_resolved_type( + rt: &crate::store::ResolvedType, + expected_id: &str, + expected_is_abstract: bool, + expected_is_final: bool, + expected_schema: Value, + expected_effective_traits: Value, + expected_effective_traits_schema: Value, +) { + assert_eq!( + rt.id, + crate::GtsTypeId::new(expected_id), + "ResolvedType.id mismatch" + ); + assert_eq!( + rt.is_abstract, expected_is_abstract, + "ResolvedType.is_abstract mismatch" + ); + assert_eq!( + rt.is_final, expected_is_final, + "ResolvedType.is_final mismatch" + ); + assert_eq!(rt.schema, expected_schema, "ResolvedType.schema mismatch"); + assert_eq!( + rt.effective_traits, expected_effective_traits, + "ResolvedType.effective_traits mismatch" + ); + assert_eq!( + rt.effective_traits_schema, expected_effective_traits_schema, + "ResolvedType.effective_traits_schema mismatch" + ); +} + +#[test] +fn test_resolved_type_single_level_full_artifacts() { + // Single level, no `$ref`s: the resolved `schema` is the body verbatim + // (x-gts-* extension keys retained), provided trait values win, and the + // effective trait-schema is the lone level's trait-schema with the leaf + // `$schema` dialect injected. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.rs.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object", "properties": { + "tier": {"type": "string", "default": "standard"}, + "retention": {"type": "string"} + }}, + "x-gts-traits": {"tier": "gold", "retention": "P30D"} + }), + ) + .unwrap(); + + let rt = store.validate_schema("gts.x.rs.tr.base.v1~").unwrap(); + assert_resolved_type( + &rt, + "gts.x.rs.tr.base.v1~", + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object", "properties": { + "tier": {"type": "string", "default": "standard"}, + "retention": {"type": "string"} + }}, + "x-gts-traits": {"tier": "gold", "retention": "P30D"} + }), + json!({"tier": "gold", "retention": "P30D"}), + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "tier": {"type": "string", "default": "standard"}, + "retention": {"type": "string"} + } + }), + ); +} + +#[test] +fn test_resolved_type_single_level_default_materialized() { + // No trait value provided: the ancestor `default` is materialized into the + // effective traits, and the trait-schema is surfaced verbatim (with dialect). + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.ep.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object", "properties": { + "retention": {"type": "string", "default": "P30D"} + }} + }), + ) + .unwrap(); + + let rt = store.validate_schema("gts.x.ep.tr.base.v1~").unwrap(); + assert_resolved_type( + &rt, + "gts.x.ep.tr.base.v1~", + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object", "properties": { + "retention": {"type": "string", "default": "P30D"} + }} + }), + json!({"retention": "P30D"}), + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"retention": {"type": "string", "default": "P30D"}} + }), + ); +} + +#[test] +fn test_resolved_type_canonical_derivation_merges_allof() { + // Canonical GTS derivation: the leaf composes via `allOf: [{$ref base}, {own + // constraints}]`. The resolved `schema` is a faithful $ref-inlined copy — + // the `allOf` is preserved and the base body is inlined into its first + // branch ($id/$schema stripped). Cross-branch composition is left to the + // JSON Schema validator, not flattened away. Trait merge/composition is + // computed separately: the effective traits merge across the chain (leaf + // wins) and the effective trait-schema is the base-declared trait-schema. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.t2.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"baseField": {"type": "string"}}, + "required": ["baseField"], + "x-gts-traits-schema": {"type": "object", "properties": { + "tier": {"type": "string", "default": "standard"}, + "retention": {"type": "string"} + }}, + "x-gts-traits": {"tier": "standard"} + }), + ) + .unwrap(); + store + .register_schema( + "gts.x.t2.tr.base.v1~x.t2._.leaf.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$ref": "gts://gts.x.t2.tr.base.v1~"}, + {"type": "object", "properties": {"leafField": {"type": "integer"}}, "required": ["leafField"]} + ], + "x-gts-traits": {"tier": "premium", "retention": "P90D"} + }), + ) + .unwrap(); + + let rt = store + .validate_schema("gts.x.t2.tr.base.v1~x.t2._.leaf.v1~") + .unwrap(); + assert_resolved_type( + &rt, + "gts.x.t2.tr.base.v1~x.t2._.leaf.v1~", + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + { + // base inlined into the branch: its root-level x-gts-* and + // $id/$schema are stripped (only meaningful at a type root). + "type": "object", + "properties": {"baseField": {"type": "string"}}, + "required": ["baseField"] + }, + { + "type": "object", + "properties": {"leafField": {"type": "integer"}}, + "required": ["leafField"] + } + ], + // the leaf's OWN root-level x-gts-traits is preserved (root, not inlined). + "x-gts-traits": {"tier": "premium", "retention": "P90D"} + }), + json!({"tier": "premium", "retention": "P90D"}), + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "tier": {"type": "string", "default": "standard"}, + "retention": {"type": "string"} + } + }), + ); +} + +#[test] +fn test_resolved_type_derivation_top_level_properties_alongside_allof() { + // Non-canonical (but valid JSON Schema) derivation: the leaf declares its + // own `properties`/`required` at the TOP LEVEL, next to `allOf: [{$ref + // base}]`. Faithful resolution keeps both: the host's own `leafField` stays + // at the top level and the inlined base lives in the preserved `allOf` + // branch — nothing is dropped or merged away. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.tla.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"baseField": {"type": "string"}}, + "required": ["baseField"] + }), + ) + .unwrap(); + store + .register_schema( + "gts.x.tla.tr.base.v1~x.tla._.leaf.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"leafField": {"type": "integer"}}, + "required": ["leafField"], + "allOf": [{"$ref": "gts://gts.x.tla.tr.base.v1~"}] + }), + ) + .unwrap(); + + let rt = store + .validate_schema("gts.x.tla.tr.base.v1~x.tla._.leaf.v1~") + .unwrap(); + assert_resolved_type( + &rt, + "gts.x.tla.tr.base.v1~x.tla._.leaf.v1~", + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"leafField": {"type": "integer"}}, + "required": ["leafField"], + "allOf": [ + { + "type": "object", + "properties": {"baseField": {"type": "string"}}, + "required": ["baseField"] + } + ] + }), + json!({}), + json!({"$schema": "http://json-schema.org/draft-07/schema#"}), + ); +} + +#[test] +fn test_resolved_type_abstract_full_artifacts() { + // Abstract type: artifacts are still fully materialized — the unresolved + // required `topicRef` is simply absent from the effective traits (no error), + // the `tier` default is materialized, and the trait-schema is surfaced with + // its `required` intact. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.p3.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-abstract": true, + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object", "properties": { + "topicRef": {"type": "string"}, + "tier": {"type": "string", "default": "standard"} + }, "required": ["topicRef"]} + }), + ) + .unwrap(); + + let rt = store.validate_schema("gts.x.p3.tr.base.v1~").unwrap(); + assert_resolved_type( + &rt, + "gts.x.p3.tr.base.v1~", + true, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-abstract": true, + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": {"type": "object", "properties": { + "topicRef": {"type": "string"}, + "tier": {"type": "string", "default": "standard"} + }, "required": ["topicRef"]} + }), + json!({"tier": "standard"}), + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "topicRef": {"type": "string"}, + "tier": {"type": "string", "default": "standard"} + }, + "required": ["topicRef"] + }), + ); +} + +#[test] +fn test_resolved_type_final_flag() { + // `x-gts-final: true` surfaces as `is_final` on the resolved type (and the + // modifier is retained verbatim in the resolved `schema`). + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.fin.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-final": true, + "properties": {"id": {"type": "string"}} + }), + ) + .unwrap(); + + let rt = store.validate_schema("gts.x.fin.tr.base.v1~").unwrap(); + assert_resolved_type( + &rt, + "gts.x.fin.tr.base.v1~", + false, + true, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-final": true, + "properties": {"id": {"type": "string"}} + }), + json!({}), + json!({"$schema": "http://json-schema.org/draft-07/schema#"}), + ); +} + +#[test] +fn test_resolved_type_false_traits_schema() { + // `x-gts-traits-schema: false` (opt-out): no values, the effective traits + // are empty, and the effective trait-schema is the boolean `false`. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.t5.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": false + }), + ) + .unwrap(); + + let rt = store.validate_schema("gts.x.t5.tr.base.v1~").unwrap(); + assert_resolved_type( + &rt, + "gts.x.t5.tr.base.v1~", + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "x-gts-traits-schema": false + }), + json!({}), + json!(false), + ); +} + +#[test] +fn test_resolved_type_true_traits_schema() { + // `x-gts-traits-schema: true` (accept-anything): arbitrary values pass + // through verbatim and the effective trait-schema is the boolean `true` + // (a boolean schema carries no `$schema` dialect to inject). + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.t6.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": true, + "x-gts-traits": {"anything": 42} + }), + ) + .unwrap(); + + let rt = store.validate_schema("gts.x.t6.tr.base.v1~").unwrap(); + assert_resolved_type( + &rt, + "gts.x.t6.tr.base.v1~", + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": true, + "x-gts-traits": {"anything": 42} + }), + json!({"anything": 42}), + json!(true), + ); +} + +#[test] +fn test_validate_payload_ok_and_reject() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.vp.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}} + }), + ) + .unwrap(); + + assert!( + store + .validate_payload("gts.x.vp.tr.base.v1~", &json!({"id": "x"})) + .is_ok() + ); + assert!( + store + .validate_payload("gts.x.vp.tr.base.v1~", &json!({})) + .is_err() + ); +} + +#[test] +fn test_validate_payload_rejects_abstract_type() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.vp.tr.abs.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-abstract": true, + "properties": {"id": {"type": "string"}} + }), + ) + .unwrap(); + + let err = store + .validate_payload("gts.x.vp.tr.abs.v1~", &json!({"id": "x"})) + .unwrap_err(); + assert!(format!("{err}").contains("abstract")); +} + +#[test] +fn test_schema_traits_ok_and_type_error() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.vt.tr.good.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": {"type": "object", "properties": { + "maxRetries": {"type": "integer", "minimum": 0, "default": 3} + }}, + "x-gts-traits": {"maxRetries": 5} + }), + ) + .unwrap(); + assert!(store.validate_schema("gts.x.vt.tr.good.v1~").is_ok()); + + store + .register_schema( + "gts.x.vt.tr.bad.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": {"type": "object", "properties": { + "maxRetries": {"type": "integer", "minimum": 0, "default": 3} + }}, + "x-gts-traits": {"maxRetries": "x"} + }), + ) + .unwrap(); + assert!(store.validate_schema("gts.x.vt.tr.bad.v1~").is_err()); +} + +#[test] +fn test_schema_traits_prohibited_by_false_schema() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.vt.tr.no_good.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": false + }), + ) + .unwrap(); + assert!(store.validate_schema("gts.x.vt.tr.no_good.v1~").is_ok()); + + store + .register_schema( + "gts.x.vt.tr.no_bad.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": false, + "x-gts-traits": {"any": 1} + }), + ) + .unwrap(); + assert!(store.validate_schema("gts.x.vt.tr.no_bad.v1~").is_err()); +} + +#[test] +fn test_trait_schema_resolves_local_defs_ref() { + // A `$ref` inside `x-gts-traits-schema` that points at the host document's + // own `$defs` (a JSON Pointer fragment, per gts-spec §9.7.5) must resolve + // against the host document — not against the bare extracted trait fragment, + // which carries no `$defs` of its own. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.dr.tr.good.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "$defs": { + "Retention": {"type": "string", "enum": ["P30D", "P365D"]} + }, + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"$ref": "#/$defs/Retention"}} + }, + "x-gts-traits": {"retention": "P30D"} + }), + ) + .unwrap(); + assert!( + store.validate_schema("gts.x.dr.tr.good.v1~").is_ok(), + "valid trait value must pass once the $defs ref resolves" + ); + + store + .register_schema( + "gts.x.dr.tr.bad.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}}, + "$defs": { + "Retention": {"type": "string", "enum": ["P30D", "P365D"]} + }, + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"$ref": "#/$defs/Retention"}} + }, + "x-gts-traits": {"retention": "NOPE"} + }), + ) + .unwrap(); + assert!( + store.validate_schema("gts.x.dr.tr.bad.v1~").is_err(), + "trait value violating the $defs-referenced enum must be rejected" + ); +} + +#[test] +fn test_trait_schema_cross_doc_fragment_ref_does_not_break_validation() { + // ADR-0002 Variant 2B: a descendant MAY compose its trait-schema with an + // explicit `allOf` + `$ref` into an ancestor's `#/x-gts-traits-schema`. + // This is redundant under 2A (the registry already chain-aggregates the + // ancestor's declaration), but it is "not invalid" — it MUST NOT break + // validation. The base's `retention` constraint reaches the effective + // trait-schema via the `$id`-chain walk regardless of the explicit ref. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.cd.tr.base.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string", "enum": ["P30D", "P365D"]}} + } + }), + ) + .unwrap(); + store + .register_schema( + "gts.x.cd.tr.base.v1~x.cd._.derived.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "allOf": [ + {"$ref": "gts://gts.x.cd.tr.base.v1~#/x-gts-traits-schema"}, + {"type": "object", "properties": {"tier": {"type": "string"}}} + ] + }, + "x-gts-traits": {"retention": "P30D", "tier": "gold"} + }), + ) + .unwrap(); + + let id = "gts.x.cd.tr.base.v1~x.cd._.derived.v1~"; + assert!( + store.validate_schema(id).is_ok(), + "valid trait values must pass despite the redundant cross-doc fragment ref" + ); + + store + .register_schema( + "gts.x.cd.tr.base.v1~x.cd._.bad.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits-schema": { + "allOf": [ + {"$ref": "gts://gts.x.cd.tr.base.v1~#/x-gts-traits-schema"}, + {"type": "object", "properties": {"tier": {"type": "string"}}} + ] + }, + "x-gts-traits": {"retention": "NOPE"} + }), + ) + .unwrap(); + assert!( + store + .validate_schema("gts.x.cd.tr.base.v1~x.cd._.bad.v1~") + .is_err(), + "ancestor enum constraint must still be enforced" + ); +} + +// --------------------------------------------------------------------------- +// OP#13 trait validation over 3-level chains (base -> mid -> leaf). +// +// Each level is registered as a standalone `{"type":"object"}` body so OP#12 +// schema-compatibility is trivially satisfied and the assertions isolate the +// trait (`x-gts-traits-schema` / `x-gts-traits`) behavior. The matrix below +// exercises: traits-schema present at one / several / no levels, `true` and +// `false` boolean forms anywhere in the chain, and conforming vs. violating +// `x-gts-traits` values. +// --------------------------------------------------------------------------- + +/// Register a `{"type":"object"}` schema carrying the given `x-gts-*` members. +/// `extra` is merged into the document root. +fn register_trait_level(store: &mut GtsStore, id: &str, extra: Value) { + let mut doc = serde_json::Map::new(); + doc.insert( + "$schema".to_owned(), + json!("http://json-schema.org/draft-07/schema#"), + ); + doc.insert("type".to_owned(), json!("object")); + if let Value::Object(m) = extra { + for (k, v) in m { + doc.insert(k, v); + } + } + store + .register_schema(id, &Value::Object(doc)) + .unwrap_or_else(|e| panic!("register {id}: {e:?}")); +} + +#[test] +fn test_op13_chain3_schema_at_base_only_conforms() { + // 1.1 (traits-schema absent at the intermediate level) + 1.3 (conform). + // Base declares the trait-schema, mid contributes nothing, the leaf supplies + // conforming values. The base declaration must reach the leaf across the gap. + let mut store = GtsStore::new(); + let base = "gts.x.c3a.tr.base.v1~"; + let mid = "gts.x.c3a.tr.base.v1~x.c3a._.mid.v1~"; + let leaf = "gts.x.c3a.tr.base.v1~x.c3a._.mid.v1~x.c3a._.leaf.v1~"; + + register_trait_level( + &mut store, + base, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}, "tier": {"type": "string"}} + }}), + ); + register_trait_level(&mut store, mid, json!({})); + register_trait_level( + &mut store, + leaf, + json!({"x-gts-traits": {"retention": "P30D", "tier": "gold"}}), + ); + + let traits = store.effective_traits(leaf).expect("effective traits"); + assert_eq!( + traits.resolved_trait_schemas.len(), + 1, + "only the base contributes a trait-schema across the 3-level chain" + ); + assert!( + store.validate_schema(leaf).is_ok(), + "leaf values conform to the base-declared trait-schema" + ); +} + +#[test] +fn test_op13_chain3_schema_at_base_only_rejects_wrong_type() { + // 1.1 (absent at mid) + 1.4 (non-conform): wrong value type at the leaf. + let mut store = GtsStore::new(); + let base = "gts.x.c3b.tr.base.v1~"; + let mid = "gts.x.c3b.tr.base.v1~x.c3b._.mid.v1~"; + let leaf = "gts.x.c3b.tr.base.v1~x.c3b._.mid.v1~x.c3b._.leaf.v1~"; + + register_trait_level( + &mut store, + base, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}} + }}), + ); + register_trait_level(&mut store, mid, json!({})); + register_trait_level( + &mut store, + leaf, + json!({"x-gts-traits": {"retention": 123}}), + ); + + let err = store.validate_schema(leaf).unwrap_err(); + assert!( + format!("{err}").contains("trait validation failed"), + "wrong-typed leaf trait value must be rejected: {err}" + ); +} + +#[test] +fn test_op13_chain3_schema_composed_across_two_levels() { + // 1.3 + 1.4 with constraints contributed by BOTH base and mid (allOf + // composition across the chain). The leaf must satisfy the merged schema; + // a value that satisfies the base but violates the mid's enum is rejected. + let mut store = GtsStore::new(); + let base = "gts.x.c3c.tr.base.v1~"; + let mid = "gts.x.c3c.tr.base.v1~x.c3c._.mid.v1~"; + let leaf = "gts.x.c3c.tr.base.v1~x.c3c._.mid.v1~x.c3c._.leaf.v1~"; + + register_trait_level( + &mut store, + base, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"] + }}), + ); + register_trait_level( + &mut store, + mid, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"tier": {"type": "string", "enum": ["gold", "silver"]}}, + "required": ["tier"] + }}), + ); + register_trait_level( + &mut store, + leaf, + json!({"x-gts-traits": {"retention": "P30D", "tier": "gold"}}), + ); + + let traits = store.effective_traits(leaf).expect("effective traits"); + assert_eq!( + traits.resolved_trait_schemas.len(), + 2, + "base and mid each contribute a trait-schema" + ); + assert!( + store.validate_schema(leaf).is_ok(), + "leaf satisfies both base and mid trait constraints" + ); + + // A leaf that satisfies the base's `required` but violates the mid's enum. + let bad = "gts.x.c3c.tr.base.v1~x.c3c._.mid.v1~x.c3c._.bad.v1~"; + register_trait_level( + &mut store, + bad, + json!({"x-gts-traits": {"retention": "P30D", "tier": "bronze"}}), + ); + assert!( + store.validate_schema(bad).is_err(), + "value violating the mid-level enum must be rejected" + ); +} + +#[test] +fn test_op13_chain_traits_schema_true_accepts_any_values() { + // 1.2 (`true`) + 1.3: a `true` trait-schema means "accept anything", so + // arbitrary trait values are valid. Covered both at a single level and when + // `true` is the only contribution across a 3-level chain. + let mut store = GtsStore::new(); + let solo = "gts.x.c3t.tr.solo.v1~"; + register_trait_level( + &mut store, + solo, + json!({"x-gts-traits-schema": true, "x-gts-traits": {"anything": 42, "x": "y"}}), + ); + let traits = store.effective_traits(solo).expect("effective traits"); + assert_eq!( + traits.resolved_trait_schemas.len(), + 1, + "`true` is a present trait-schema contribution" + ); + assert!( + store.validate_schema(solo).is_ok(), + "`true` trait-schema accepts arbitrary trait values" + ); + + // `true` at the base, values at the leaf of a 3-level chain. + let base = "gts.x.c3t.tr.base.v1~"; + let mid = "gts.x.c3t.tr.base.v1~x.c3t._.mid.v1~"; + let leaf = "gts.x.c3t.tr.base.v1~x.c3t._.mid.v1~x.c3t._.leaf.v1~"; + register_trait_level(&mut store, base, json!({"x-gts-traits-schema": true})); + register_trait_level(&mut store, mid, json!({})); + register_trait_level( + &mut store, + leaf, + json!({"x-gts-traits": {"whatever": [1, 2, 3]}}), + ); + assert!( + store.validate_schema(leaf).is_ok(), + "`true` declared at the base accepts any leaf values across the chain" + ); +} + +#[test] +fn test_op13_chain3_false_at_base_prohibits_descendant_values() { + // 1.2 (`false`) in a multi-level chain: `false` anywhere makes the composed + // trait-schema unsatisfiable, so descendant values are prohibited but the + // absence of values is fine. + let mut store = GtsStore::new(); + let base = "gts.x.c3f.tr.base.v1~"; + let mid = "gts.x.c3f.tr.base.v1~x.c3f._.mid.v1~"; + let leaf_ok = "gts.x.c3f.tr.base.v1~x.c3f._.mid.v1~x.c3f._.ok.v1~"; + let leaf_bad = "gts.x.c3f.tr.base.v1~x.c3f._.mid.v1~x.c3f._.bad.v1~"; + + register_trait_level(&mut store, base, json!({"x-gts-traits-schema": false})); + register_trait_level(&mut store, mid, json!({})); + register_trait_level(&mut store, leaf_ok, json!({})); + register_trait_level(&mut store, leaf_bad, json!({"x-gts-traits": {"x": 1}})); + + assert!( + store.validate_schema(leaf_ok).is_ok(), + "`false` trait-schema with no values is allowed" + ); + let err = store.validate_schema(leaf_bad).unwrap_err(); + assert!( + format!("{err}").contains("prohibited"), + "`false` in the chain must prohibit descendant trait values: {err}" + ); +} + +#[test] +fn test_op13_chain3_false_at_intermediate_overrides_real_base_schema() { + // 1.2 (`false`) introduced at the INTERMEDIATE level while the base declares + // a real object schema. The `false` still makes the composed schema + // unsatisfiable, so leaf values that would satisfy the base alone are + // nonetheless prohibited. + let mut store = GtsStore::new(); + let base = "gts.x.c3fi.tr.base.v1~"; + let mid = "gts.x.c3fi.tr.base.v1~x.c3fi._.mid.v1~"; + let leaf = "gts.x.c3fi.tr.base.v1~x.c3fi._.mid.v1~x.c3fi._.leaf.v1~"; + + register_trait_level( + &mut store, + base, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}} + }}), + ); + register_trait_level(&mut store, mid, json!({"x-gts-traits-schema": false})); + register_trait_level( + &mut store, + leaf, + json!({"x-gts-traits": {"retention": "P30D"}}), + ); + + let err = store.validate_schema(leaf).unwrap_err(); + assert!( + format!("{err}").contains("prohibited"), + "`false` at the mid level prohibits values even though the base schema would accept them: {err}" + ); +} + +#[test] +fn test_op13_chain3_schema_only_at_intermediate() { + // 1.1 (traits-schema absent at base AND leaf, present only at the mid) + + // 1.3 / 1.4. The mid's constraint must govern the leaf's values. + let mut store = GtsStore::new(); + let base = "gts.x.c3m.tr.base.v1~"; + let mid = "gts.x.c3m.tr.base.v1~x.c3m._.mid.v1~"; + let leaf_ok = "gts.x.c3m.tr.base.v1~x.c3m._.mid.v1~x.c3m._.ok.v1~"; + let leaf_bad = "gts.x.c3m.tr.base.v1~x.c3m._.mid.v1~x.c3m._.bad.v1~"; + + register_trait_level(&mut store, base, json!({})); + register_trait_level( + &mut store, + mid, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"tier": {"type": "string", "enum": ["a", "b"]}} + }}), + ); + register_trait_level(&mut store, leaf_ok, json!({"x-gts-traits": {"tier": "a"}})); + register_trait_level(&mut store, leaf_bad, json!({"x-gts-traits": {"tier": "z"}})); + + assert!( + store.validate_schema(leaf_ok).is_ok(), + "value conforming to the mid-only trait-schema passes" + ); + assert!( + store.validate_schema(leaf_bad).is_err(), + "value violating the mid-only enum is rejected" + ); +} + +#[test] +fn test_op13_chain3_no_schema_anywhere() { + // 1.1 fully absent across a 3-level chain. No values -> ok; values present + // with no trait-schema anywhere in the chain -> error. + let mut store = GtsStore::new(); + let base = "gts.x.c3n.tr.base.v1~"; + let mid = "gts.x.c3n.tr.base.v1~x.c3n._.mid.v1~"; + let leaf_ok = "gts.x.c3n.tr.base.v1~x.c3n._.mid.v1~x.c3n._.ok.v1~"; + let leaf_bad = "gts.x.c3n.tr.base.v1~x.c3n._.mid.v1~x.c3n._.bad.v1~"; + + register_trait_level(&mut store, base, json!({})); + register_trait_level(&mut store, mid, json!({})); + register_trait_level(&mut store, leaf_ok, json!({})); + register_trait_level(&mut store, leaf_bad, json!({"x-gts-traits": {"foo": 1}})); + + assert!( + store.validate_schema(leaf_ok).is_ok(), + "no trait-schema and no values anywhere in the chain is valid" + ); + let err = store.validate_schema(leaf_bad).unwrap_err(); + assert!( + format!("{err}").contains("no x-gts-traits-schema"), + "trait values with no trait-schema in the chain must be rejected: {err}" + ); +} + +#[test] +fn test_op13_chain4_merge_defaults_consts_nulls_via_validate_schema() { + // Four-level derivation (base -> l1 -> l2 -> leaf) exercised through the full + // `validate_schema` path. The base declares the only trait-schema; values are + // contributed at every level. Asserts the exact materialized + // `effective_traits` so the RFC-7396 merge + default/const/null handling is + // pinned end to end: + // - tier: base "standard" -> l1 "premium" => leaf-most wins + // - region: base "eu" -> l2 `null` (delete) => falls back to default "us" + // - retention: only leaf "P90D" => overrides default + // - locked: never provided, schema `const: "X"` => const materializes + // - optional: never provided, schema `default: "d"` => default materializes + let mut store = GtsStore::new(); + let base = "gts.x.c4.tr.base.v1~"; + let l1 = "gts.x.c4.tr.base.v1~x.c4._.l1.v1~"; + let l2 = "gts.x.c4.tr.base.v1~x.c4._.l1.v1~x.c4._.l2.v1~"; + let leaf = "gts.x.c4.tr.base.v1~x.c4._.l1.v1~x.c4._.l2.v1~x.c4._.leaf.v1~"; + + register_trait_level( + &mut store, + base, + json!({ + "x-gts-traits-schema": {"type": "object", "properties": { + "retention": {"type": "string", "default": "P30D"}, + "tier": {"type": "string"}, + "region": {"type": "string", "default": "us"}, + "locked": {"type": "string", "const": "X"}, + "optional": {"type": "string", "default": "d"} + }}, + "x-gts-traits": {"tier": "standard", "region": "eu"} + }), + ); + register_trait_level(&mut store, l1, json!({"x-gts-traits": {"tier": "premium"}})); + register_trait_level(&mut store, l2, json!({"x-gts-traits": {"region": null}})); + register_trait_level( + &mut store, + leaf, + json!({"x-gts-traits": {"retention": "P90D"}}), + ); + + let rt = store + .validate_schema(leaf) + .expect("4-level chain must validate"); + assert_eq!( + rt.effective_traits, + json!({ + "retention": "P90D", + "tier": "premium", + "region": "us", + "locked": "X", + "optional": "d" + }), + "merge across 4 levels must honor leaf-wins, null-delete->default, const, and default" + ); +} + +#[test] +fn test_op13_trait_schema_allof_ref_resolves_default_and_enforces() { + // `x-gts-traits-schema` is itself a JSON subschema that may compose other + // registered schemas via `allOf` + `$ref`. Those refs must resolve so that + // (a) a `default` declared in the referenced schema materializes into the + // effective traits, and (b) the referenced constraints (here an `enum`) are + // enforced against the merged trait values. + let mut store = GtsStore::new(); + register_trait_level( + &mut store, + "gts.x.rd.tr.retention.v1~", + json!({"properties": { + "retention": {"type": "string", "enum": ["P30D", "P365D"], "default": "P30D"} + }}), + ); + register_trait_level( + &mut store, + "gts.x.rd.tr.base.v1~", + json!({"x-gts-traits-schema": { + "type": "object", + "allOf": [{"$ref": "gts://gts.x.rd.tr.retention.v1~"}] + }}), + ); + + // The composed effective trait-schema is identical for every leaf below: the + // base's `allOf` with the `$ref` inlined (its `$id`/`$schema` stripped) and + // the dialect re-injected from the leaf. + let expected_traits_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [{ + "type": "object", + "properties": { + "retention": {"type": "string", "enum": ["P30D", "P365D"], "default": "P30D"} + } + }] + }); + + // (a) leaf omits the value -> the referenced schema's default materializes. + let dflt = "gts.x.rd.tr.base.v1~x.rd._.dflt.v1~"; + register_trait_level(&mut store, dflt, json!({})); + let rt = store + .validate_schema(dflt) + .expect("default from $ref must resolve"); + assert_resolved_type( + &rt, + dflt, + false, + false, + json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}), + json!({"retention": "P30D"}), + expected_traits_schema.clone(), + ); + + // (b) a value within the referenced enum passes and is carried through. + let ok = "gts.x.rd.tr.base.v1~x.rd._.ok.v1~"; + register_trait_level( + &mut store, + ok, + json!({"x-gts-traits": {"retention": "P365D"}}), + ); + let rt = store + .validate_schema(ok) + .expect("value within the $ref'd enum must pass"); + assert_resolved_type( + &rt, + ok, + false, + false, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-gts-traits": {"retention": "P365D"} + }), + json!({"retention": "P365D"}), + expected_traits_schema, + ); + + // (c) a value violating the referenced enum is rejected. + let bad = "gts.x.rd.tr.base.v1~x.rd._.bad.v1~"; + register_trait_level( + &mut store, + bad, + json!({"x-gts-traits": {"retention": "NOPE"}}), + ); + assert!( + store.validate_schema(bad).is_err(), + "value violating the $ref'd enum must be rejected" + ); +} + +#[test] +fn test_op13_abstract_rejects_wrong_typed_trait_value() { + // Abstract types skip the required-trait *completeness* check, but a trait + // value that IS provided must still satisfy its declared type — a `string` + // where the schema demands an `integer` is rejected even on an abstract type. + let mut store = GtsStore::new(); + let id = "gts.x.abst.tr.wt.v1~"; + register_trait_level( + &mut store, + id, + json!({ + "x-gts-abstract": true, + "x-gts-traits-schema": { + "type": "object", + "properties": {"maxRetries": {"type": "integer"}} + }, + "x-gts-traits": {"maxRetries": "not_a_number"} + }), + ); + let err = store.validate_schema(id).unwrap_err(); + assert!( + format!("{err}").contains("trait validation failed"), + "abstract type must still type-check provided trait values: {err}" + ); +} + +#[test] +fn test_op13_abstract_skips_required_completeness() { + // A required trait with no default and no value is allowed on an abstract + // type: a derived type may supply it later, so completeness is deferred. + let mut store = GtsStore::new(); + let id = "gts.x.abst.tr.req.v1~"; + register_trait_level( + &mut store, + id, + json!({ + "x-gts-abstract": true, + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"] + } + }), + ); + assert!( + store.validate_schema(id).is_ok(), + "abstract type may leave a required trait unresolved for descendants" + ); +} + +#[test] +fn test_op13_abstract_base_required_enforced_at_concrete_leaf() { + // The required trait deferred by an abstract base is enforced once a + // concrete descendant closes the surface: a leaf that resolves it passes, + // one that leaves it unresolved fails the completeness check. + let mut store = GtsStore::new(); + let base = "gts.x.abst.tr.base.v1~"; + register_trait_level( + &mut store, + base, + json!({ + "x-gts-abstract": true, + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"] + } + }), + ); + assert!( + store.validate_schema(base).is_ok(), + "abstract base with an unresolved required trait is valid" + ); + + let good = "gts.x.abst.tr.base.v1~x.abst._.good.v1~"; + register_trait_level( + &mut store, + good, + json!({"x-gts-traits": {"topicRef": "orders"}}), + ); + assert!( + store.validate_schema(good).is_ok(), + "concrete leaf that resolves the required trait passes" + ); + + let bad = "gts.x.abst.tr.base.v1~x.abst._.bad.v1~"; + register_trait_level(&mut store, bad, json!({})); + assert!( + store.validate_schema(bad).is_err(), + "concrete leaf that leaves the required trait unresolved is rejected" + ); +} + +#[test] +fn test_validate_schema_accepts_gts_ref_with_pointer_fragment() { + // A GTS `$ref` carrying a JSON Pointer fragment (e.g. selecting a + // sub-schema of the target) is supported by the resolver and by + // `extract_gts_refs`; `validate_schema_refs` must accept it too rather than + // rejecting the whole `id#fragment` string as an invalid type id. + let mut store = GtsStore::new(); + + store + .register_schema( + "gts.vendor.package.namespace.base.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.base.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"name": {"type": "string"}} + }), + ) + .expect("register base"); + + store + .register_schema( + "gts.vendor.package.namespace.type.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "inner": { + "$ref": "gts://gts.vendor.package.namespace.base.v1.0~#/properties/name" + } + } + }), + ) + .expect("register type"); + + store + .validate_schema_refs("gts.vendor.package.namespace.type.v1.0~") + .expect("fragment $ref must validate"); +} + +#[test] +fn test_validate_schema_rejects_gts_ref_with_non_pointer_fragment() { + // Only an empty fragment or a `/`-prefixed JSON Pointer is supported; a + // bare anchor fragment the resolver cannot dereference must be rejected. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.vendor.package.namespace.type.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "inner": { + "$ref": "gts://gts.vendor.package.namespace.base.v1.0~#anchor" + } + } + }), + ) + .expect("register type"); + + assert!(matches!( + store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"), + Err(StoreError::InvalidRef(_)) + )); +} + +#[test] +fn test_validate_and_resolve_meta_validates_resolved_schema() { + // `validate_schema_refs` only checks `$ref`/`x-gts-ref` structure, so a + // structurally malformed body slips past registration-time checks. + // `validate_schema` must compile the fully-resolved schema and reject it. + let mut store = GtsStore::new(); + + store + .register_schema( + "gts.vendor.package.namespace.dep.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.dep.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"d": {"type": "string"}} + }), + ) + .expect("register dep"); + + // `"type": 123` is invalid per the JSON Schema meta-schema. + let malformed = json!({ + "$id": "gts://gts.vendor.package.namespace.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "ref": {"$ref": "gts://gts.vendor.package.namespace.dep.v1.0~"}, + "bad": {"type": 123} + } + }); + store + .register_schema("gts.vendor.package.namespace.type.v1.0~", &malformed) + .expect("register type"); + + // Registration-time validation only checks ref structure, not the body. + store + .validate_schema_refs("gts.vendor.package.namespace.type.v1.0~") + .expect("validate_schema_refs checks ref structure only"); + + // But the single-pass API now compiles the resolved schema and rejects it. + assert!(matches!( + store.validate_schema("gts.vendor.package.namespace.type.v1.0~"), + Err(StoreError::ValidationError(_)) + )); +} + +#[test] +fn test_validate_and_resolve_accepts_well_formed_gts_ref_schema() { + // The added meta-validation must not reject a structurally valid schema + // whose only `gts://` dependency is registered. + let mut store = GtsStore::new(); + + store + .register_schema( + "gts.vendor.package.namespace.dep.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.dep.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"d": {"type": "string"}} + }), + ) + .expect("register dep"); + + store + .register_schema( + "gts.vendor.package.namespace.type.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "ref": {"$ref": "gts://gts.vendor.package.namespace.dep.v1.0~"} + } + }), + ) + .expect("register type"); + + store + .validate_schema("gts.vendor.package.namespace.type.v1.0~") + .expect("well-formed schema must validate and resolve"); +} + +// --------------------------------------------------------------------------- +// `GtsStore` `$ref`-resolution wrappers (`resolve_schema_refs` / +// `try_resolve_schema_refs`) and the store-as-`SchemaProvider` integration. +// Resolver semantics themselves are unit-tested in `schema_resolver_test.rs`; +// these are smoke/integration tests for the store-level surface. +// --------------------------------------------------------------------------- + +#[test] +fn test_resolve_schema_refs_wrapper_smoke() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.core.events.type.v1~", + &json!({ + "$id": "gts://gts.x.core.events.type.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register target"); + + let resolved = store.resolve_schema_refs(&json!({"$ref": "gts://gts.x.core.events.type.v1~"})); + assert_eq!( + resolved, + json!({"type": "object", "properties": {"id": {"type": "string"}}}) + ); +} + +#[test] +fn test_try_resolve_schema_refs_wrapper_smoke() { + let store = GtsStore::new(); + let err = store + .try_resolve_schema_refs(&json!({"$ref": "gts://gts.x.core.events.missing.v1~"})) + .expect_err("unresolved external ref must fail checked resolution"); + assert!(matches!( + &err, + StoreError::UnresolvedRefs(refs) + if refs == &["gts://gts.x.core.events.missing.v1~".to_owned()] + )); +} + +#[test] +fn test_resolve_schema_refs_uses_exact_gts_uri_lookup_without_minor_fallback() { + // The store's `SchemaProvider` lookup is exact: a `v1~` ref does not resolve + // against a stored `v1.0~` schema. + let mut store = GtsStore::new(); + store + .register_schema( + "gts.x.core.events.type.v1.0~", + &json!({ + "$id": "gts://gts.x.core.events.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"minor": {"const": "v1.0"}} + }), + ) + .expect("register target schema"); + + let schema = json!({"$ref": "gts://gts.x.core.events.type.v1~"}); + assert_eq!( + store.resolve_schema_refs(&schema), + schema, + "v1~ must not resolve against a stored v1.0~ schema" + ); + + let err = store + .try_resolve_schema_refs(&schema) + .expect_err("checked resolution should reject the unresolved v1~ ref"); + assert!(matches!( + &err, + StoreError::UnresolvedRefs(refs) + if refs == &["gts://gts.x.core.events.type.v1~".to_owned()] + )); +} + +#[test] +fn test_validate_instance_resolves_sibling_ref_in_allof() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.vendor.package.namespace.base.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.base.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"id": {"type": "string"}} + }), + ) + .expect("register base"); + store + .register_schema( + "gts.vendor.package.namespace.type.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "gts://gts.vendor.package.namespace.base.v1.0~", + "properties": {"name": {"type": "string"}} + } + ] + }), + ) + .expect("register type"); + + let cfg = GtsConfig::default(); + let entity = GtsEntity::new( + None, + None, + &json!({"id": "gts.vendor.package.namespace.type.v1.0", "name": "test"}), + Some(&cfg), + None, + false, + String::new(), + None, + Some("gts.vendor.package.namespace.type.v1.0~".to_owned()), + ); + store.register(entity).expect("register instance"); + + assert!( + store + .validate_instance("gts.vendor.package.namespace.type.v1.0") + .is_ok(), + "resolvable sibling $ref should validate" + ); +} + +#[test] +fn test_validate_instance_reports_unresolvable_ref() { + let mut store = GtsStore::new(); + store + .register_schema( + "gts.vendor.package.namespace.type.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.type.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "$ref": "gts://gts.vendor.package.namespace.nonexistent.v1.0~", + "type": "object" + } + } + }), + ) + .expect("register type"); + + let cfg = GtsConfig::default(); + let entity = GtsEntity::new( + None, + None, + &json!({"id": "gts.vendor.package.namespace.type.v1.0", "data": {}}), + Some(&cfg), + None, + false, + String::new(), + None, + Some("gts.vendor.package.namespace.type.v1.0~".to_owned()), + ); + store.register(entity).expect("register instance"); + + let err = store + .validate_instance("gts.vendor.package.namespace.type.v1.0") + .expect_err("unresolvable ref must fail validation"); + assert!( + err.to_string() + .contains("Unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") + ); +} diff --git a/gts/src/x_gts_ref.rs b/gts/src/x_gts_ref.rs index c799155..0481ef5 100644 --- a/gts/src/x_gts_ref.rs +++ b/gts/src/x_gts_ref.rs @@ -92,7 +92,7 @@ use serde_json::Value; use std::fmt; -use crate::gts::GtsId; +use crate::gts::{GtsId, GtsIdPattern}; /// Error type for x-gts-ref validation failures #[derive(Debug, Clone)] @@ -387,7 +387,7 @@ impl XGtsRefValidator { }; // Validate against GTS pattern - self.validate_gts_pattern(value, &resolved_pattern, field_path) + self.validate_value_matches_gts_pattern(value, &resolved_pattern, field_path) } /// Validate an x-gts-ref pattern in a schema definition @@ -397,22 +397,38 @@ impl XGtsRefValidator { field_path: &str, root_schema: &Value, ) -> Option { - // Case 1: Absolute GTS pattern + // Case 1: Absolute GTS pattern. A valid `x-gts-ref` literal is either a + // concrete GTS identifier or a trailing-`*` wildcard pattern; both forms + // are validated by the canonical pattern parser, which rejects malformed + // patterns such as `gts.x.*.events.*` (mid-string / multiple wildcards). if ref_pattern.starts_with("gts.") { - return self.validate_gts_id_or_pattern(ref_pattern, field_path); + return GtsIdPattern::try_new(ref_pattern).err().map(|e| { + XGtsRefValidationError::new( + field_path.to_owned(), + ref_pattern.to_owned(), + ref_pattern.to_owned(), + format!("Invalid GTS identifier: {ref_pattern}: {}", e.cause), + ) + }); } // Case 2: Relative reference if ref_pattern.starts_with('/') { match Self::resolve_pointer(root_schema, ref_pattern) { Some(resolved) => { - if !GtsId::is_valid(&resolved) { + // The resolved target may be a concrete id or a trailing-`*` + // wildcard pattern, exactly like the absolute branch above; + // validate it through the canonical pattern parser so a + // resolved wildcard (e.g. `gts.x.core.*`) is accepted here + // just as a literal one is. + if let Err(e) = GtsIdPattern::try_new(&resolved) { return Some(XGtsRefValidationError::new( field_path.to_owned(), ref_pattern.to_owned(), ref_pattern.to_owned(), format!( - "Resolved reference '{ref_pattern}' -> '{resolved}' is not a valid GTS identifier" + "Resolved reference '{ref_pattern}' -> '{resolved}' is not a valid GTS identifier: {}", + e.cause ), )); } @@ -435,74 +451,32 @@ impl XGtsRefValidator { } } - /// Validate a GTS ID or pattern in schema definition - fn validate_gts_id_or_pattern( + /// Validate value matches a GTS pattern + fn validate_value_matches_gts_pattern( &self, + value: &str, pattern: &str, field_path: &str, ) -> Option { - // Valid wildcard - if pattern == "gts.*" { - return None; - } - - // Wildcard pattern - validate prefix - if pattern.contains('*') { - let prefix = pattern.trim_end_matches('*'); - if !prefix.starts_with("gts.") { - return Some(XGtsRefValidationError::new( - field_path.to_owned(), - pattern.to_owned(), - pattern.to_owned(), - format!("Invalid GTS wildcard pattern: {pattern}"), - )); - } - return None; - } - - // Specific GTS ID - if !GtsId::is_valid(pattern) { + let Ok(id) = GtsId::try_new(value) else { return Some(XGtsRefValidationError::new( field_path.to_owned(), + value.to_owned(), pattern.to_owned(), - pattern.to_owned(), - format!("Invalid GTS identifier: {pattern}"), + format!("Value '{value}' is not a valid GTS identifier"), )); - } - - None - } + }; - /// Validate value matches a GTS pattern - fn validate_gts_pattern( - &self, - value: &str, - pattern: &str, - field_path: &str, - ) -> Option { - // Validate it's a valid GTS ID - if !GtsId::is_valid(value) { + let Ok(pat) = GtsIdPattern::try_new(pattern) else { return Some(XGtsRefValidationError::new( field_path.to_owned(), value.to_owned(), pattern.to_owned(), - format!("Value '{value}' is not a valid GTS identifier"), + format!("Invalid GTS pattern '{pattern}'"), )); - } + }; - // Check pattern match - if pattern == "gts.*" { - // Any valid GTS ID matches - } else if let Some(prefix) = pattern.strip_suffix('*') { - if !value.starts_with(prefix) { - return Some(XGtsRefValidationError::new( - field_path.to_owned(), - value.to_owned(), - pattern.to_owned(), - format!("Value '{value}' does not match pattern '{pattern}'"), - )); - } - } else if !value.starts_with(pattern) { + if !id.matches_pattern(&pat) { return Some(XGtsRefValidationError::new( field_path.to_owned(), value.to_owned(), @@ -511,9 +485,6 @@ impl XGtsRefValidator { )); } - // Note: We don't check if the entity exists in the store here - // to avoid borrowing issues. The store check can be done separately if needed. - None } @@ -528,6 +499,17 @@ impl XGtsRefValidator { /// Note: For `/$id` references, the `gts://` prefix is stripped from the value /// as per GTS specification (relative self-reference should match the $id without the prefix). fn resolve_pointer(schema: &Value, pointer: &str) -> Option { + Self::resolve_pointer_inner(schema, pointer, 0) + } + + /// Depth-guarded pointer resolution: relative `x-gts-ref` hops recurse here, + /// and a self-referential chain would overflow the stack without the cap. + fn resolve_pointer_inner(schema: &Value, pointer: &str, depth: usize) -> Option { + const MAX_POINTER_DEPTH: usize = 64; + if depth > MAX_POINTER_DEPTH { + return None; + } + let path = pointer.trim_start_matches('/'); if path.is_empty() { return None; @@ -554,7 +536,7 @@ impl XGtsRefValidator { && let Some(ref_str) = ref_value.as_str() { if ref_str.starts_with('/') { - return Self::resolve_pointer(schema, ref_str); + return Self::resolve_pointer_inner(schema, ref_str, depth + 1); } return Some(ref_str.to_owned()); } @@ -579,11 +561,11 @@ mod tests { use serde_json::json; #[test] - fn test_validate_gts_pattern_matching() { + fn test_validate_value_matches_gts_pattern_matching() { let validator = XGtsRefValidator::new(); // Test exact match - let result = validator.validate_gts_pattern( + let result = validator.validate_value_matches_gts_pattern( "gts.x.core.events.topic.v1~", "gts.x.core.events.topic.v1~", "test_field", @@ -591,12 +573,15 @@ mod tests { assert!(result.is_none(), "Exact match should succeed"); // Test wildcard match - let result = - validator.validate_gts_pattern("gts.x.core.events.topic.v1~", "gts.*", "test_field"); + let result = validator.validate_value_matches_gts_pattern( + "gts.x.core.events.topic.v1~", + "gts.*", + "test_field", + ); assert!(result.is_none(), "Wildcard match should succeed"); // Test prefix match - let result = validator.validate_gts_pattern( + let result = validator.validate_value_matches_gts_pattern( "gts.x.core.events.topic.v1~", "gts.x.core.*", "test_field", @@ -604,7 +589,7 @@ mod tests { assert!(result.is_none(), "Prefix match should succeed"); // Test mismatch - let result = validator.validate_gts_pattern( + let result = validator.validate_value_matches_gts_pattern( "gts.x.core.events.topic.v1~", "gts.y.core.*", "test_field", @@ -650,6 +635,57 @@ mod tests { assert!(errors.is_empty()); } + #[test] + fn test_validate_schema_relative_ref_resolving_to_wildcard() { + // A relative `x-gts-ref` that resolves to a wildcard pattern must be + // accepted, matching the absolute branch (which uses `GtsIdPattern`). + // Previously the resolved value was checked with `GtsId::is_valid`, + // which rejects wildcards and produced a spurious schema error. + let validator = XGtsRefValidator::new(); + let schema = json!({ + "type": "object", + "properties": { + "anchor": { + "type": "string", + "x-gts-ref": "gts.x.core.events.topic.*" + }, + "relative": { + "type": "string", + "x-gts-ref": "/properties/anchor" + } + } + }); + + let errors = validator.validate_schema(&schema, "", None); + assert!( + errors.is_empty(), + "relative ref resolving to a wildcard must be accepted: {errors:?}" + ); + } + + #[test] + fn test_validate_schema_relative_ref_resolving_to_invalid_still_rejected() { + // The pattern parser must still reject a resolved value that is neither + // a concrete id nor a valid wildcard pattern. + let validator = XGtsRefValidator::new(); + let schema = json!({ + "type": "object", + "properties": { + "anchor": {"type": "string", "const": "not a gts id"}, + "relative": { + "type": "string", + "x-gts-ref": "/properties/anchor/const" + } + } + }); + + let errors = validator.validate_schema(&schema, "", None); + assert!( + !errors.is_empty(), + "relative ref resolving to an invalid identifier must be rejected" + ); + } + #[test] fn test_validate_instance_with_x_gts_ref_mismatch() { let validator = XGtsRefValidator::new(); @@ -766,11 +802,15 @@ mod tests { } #[test] - fn test_validate_gts_pattern_failures() { + fn test_validate_value_matches_gts_pattern_failures() { let validator = XGtsRefValidator::new(); // Test invalid GTS ID - let result = validator.validate_gts_pattern("not-a-valid-gts-id", "gts.*", "test_field"); + let result = validator.validate_value_matches_gts_pattern( + "not-a-valid-gts-id", + "gts.*", + "test_field", + ); assert!(result.is_some()); assert!( result @@ -780,55 +820,63 @@ mod tests { ); // Test prefix no match - let result = validator.validate_gts_pattern("gts.a.b.c.d.v1~", "gts.x.y.*", "test_field"); + let result = validator.validate_value_matches_gts_pattern( + "gts.a.b.c.d.v1~", + "gts.x.y.*", + "test_field", + ); assert!(result.is_some()); assert!(result.unwrap().reason.contains("does not match pattern")); // Test exact no match - let result = - validator.validate_gts_pattern("gts.a.b.c.d.v1~", "gts.x.y.z.w.v1~", "test_field"); + let result = validator.validate_value_matches_gts_pattern( + "gts.a.b.c.d.v1~", + "gts.x.y.z.w.v1~", + "test_field", + ); assert!(result.is_some()); } #[test] - fn test_validate_gts_id_or_pattern() { + fn test_validate_ref_pattern_gts_literals() { let validator = XGtsRefValidator::new(); + let no_root = json!({}); + + // Valid concrete id, bare wildcard, and prefix wildcard all pass. + for ok in ["gts.x.core.events.topic.v1~", "gts.*", "gts.x.core.*"] { + assert!( + validator + .validate_ref_pattern(ok, "test_field", &no_root) + .is_none(), + "expected '{ok}' to validate" + ); + } - // Valid exact GTS ID - assert!( - validator - .validate_gts_id_or_pattern("gts.x.core.events.topic.v1~", "test_field",) - .is_none() - ); - - // Valid wildcard - assert!( - validator - .validate_gts_id_or_pattern("gts.*", "test_field") - .is_none() - ); + // Malformed wildcard patterns (mid-string / multiple wildcards) must be + // rejected — a naive `starts_with("gts.")` prefix check accepted these. + // The reason now carries the canonical parser's error text. + for bad in ["gts.x.*.events.*", "gts.*.*.*.*"] { + let result = validator.validate_ref_pattern(bad, "test_field", &no_root); + assert!(result.is_some(), "expected '{bad}' to be rejected"); + assert!(!result.unwrap().reason.is_empty()); + } + } - // Valid prefix wildcard + #[test] + fn test_validate_value_matches_gts_pattern_minor_version_flexibility() { + let validator = XGtsRefValidator::new(); + // A pattern pinned to a major version (no minor) must match a value that + // carries a specific minor — segment-aware matching honours this; a raw + // string prefix would reject it (`…v1.0~` does not start with `…v1~`). assert!( validator - .validate_gts_id_or_pattern("gts.x.core.*", "test_field") + .validate_value_matches_gts_pattern( + "gts.x.core.events.event.v1.0~", + "gts.x.core.events.event.v1~", + "test_field", + ) .is_none() ); - - // Invalid wildcard - let result = validator.validate_gts_id_or_pattern("invalid.*", "test_field"); - assert!(result.is_some()); - assert!( - result - .unwrap() - .reason - .contains("Invalid GTS wildcard pattern") - ); - - // Invalid ID - let result = validator.validate_gts_id_or_pattern("not-a-valid-id", "test_field"); - assert!(result.is_some()); - assert!(result.unwrap().reason.contains("Invalid GTS identifier")); } #[test] @@ -1011,6 +1059,32 @@ mod tests { ); } + #[test] + fn test_resolve_pointer_self_cycle_terminates() { + // A cyclic relative x-gts-ref must terminate with None, not overflow. + let schema = json!({ + "properties": { + "a": { "x-gts-ref": "/properties/a" } + } + }); + assert_eq!( + XGtsRefValidator::resolve_pointer(&schema, "/properties/a"), + None + ); + + // Two-node cycle: a -> b -> a. + let schema = json!({ + "properties": { + "a": { "x-gts-ref": "/properties/b" }, + "b": { "x-gts-ref": "/properties/a" } + } + }); + assert_eq!( + XGtsRefValidator::resolve_pointer(&schema, "/properties/a"), + None + ); + } + #[test] fn test_visit_schema_non_string_x_gts_ref() { let validator = XGtsRefValidator::new();