From 1ecc09961ac268f7e39ec19f018908b58d47ff65 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Tue, 16 Jun 2026 20:11:31 +0300 Subject: [PATCH 1/4] fix: align GTS ID pattern handling - Derive Type Schema parent IDs from chained values instead of fallbacks. - Validate x-gts-ref literals and matches through GtsIdPattern for malformed wildcard rejection and segment-aware version matching. Signed-off-by: Aviator 5 --- gts-macros/tests/integration_tests.rs | 15 --- gts/src/entities.rs | 59 +++------ gts/src/ops.rs | 9 -- gts/src/x_gts_ref.rs | 184 ++++++++++++-------------- 4 files changed, 101 insertions(+), 166 deletions(-) diff --git a/gts-macros/tests/integration_tests.rs b/gts-macros/tests/integration_tests.rs index 364799c..bf94628 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 // ============================================================================= diff --git a/gts/src/entities.rs b/gts/src/entities.rs index 8aef502..cb59665 100644 --- a/gts/src/entities.rs +++ b/gts/src/entities.rs @@ -215,10 +215,10 @@ 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`: either the type itself (if standalone) or parent type (if chained) + /// - `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 +236,22 @@ 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) { + 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()); } } @@ -966,8 +941,9 @@ mod tests { #[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 +960,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); } // ============================================================================= diff --git a/gts/src/ops.rs b/gts/src/ops.rs index ab66e4b..7b201f8 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -911,7 +911,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 +999,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); diff --git a/gts/src/x_gts_ref.rs b/gts/src/x_gts_ref.rs index c799155..9d20b67 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,9 +397,19 @@ 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 @@ -435,74 +445,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 +479,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 } @@ -579,11 +544,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 +556,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 +572,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", @@ -766,11 +734,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 +752,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] From 09a04bdbe4efc4b732f5951ec2396464d6620713 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Thu, 18 Jun 2026 15:54:55 +0300 Subject: [PATCH 2/4] feat(gts): improve API with single-pass validation and reusable trait helpers - Split GtsStore::new(Option) into new() and with_reader() for clarity - Export ResolvedTypeSchema and ResolveSchemaRefsError for composable validation - Refactor trait validation into single-pass: validate_and_resolve_type_schema, validate_payload, validate_traits. Each builds effective traits schema once. - Extract inline_local_pointers to resolve JSON Pointer refs against host doc - Split validate_effective_traits into validate_prebuilt_effective (reusable after schema is pre-built) and effective_traits_schema_and_values (builder) - Add extract_gts_refs for discovering schema dependencies before resolution - resolve_schema_refs_checked now returns ResolveSchemaRefsError with CircularRef and UnresolvedRefs variants; preserve unresolved refs in lenient output - Add Default impl for GtsStore - Update all tests to use GtsStore::new() instead of GtsStore::new(None) Signed-off-by: Aviator 5 --- gts-macros/tests/inheritance_tests.rs | 2 +- gts-macros/tests/integration_tests.rs | 6 +- gts/src/lib.rs | 6 +- gts/src/ops.rs | 180 +++++- gts/src/schema_modifiers.rs | 22 +- gts/src/schema_refs.rs | 150 +++++ gts/src/schema_traits.rs | 588 ++++++++++++++---- gts/src/store.rs | 598 +++++++++++------- gts/src/store_test.rs | 851 ++++++++++++++++++++------ 9 files changed, 1829 insertions(+), 574 deletions(-) create mode 100644 gts/src/schema_refs.rs 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 bf94628..a99f4b4 100644 --- a/gts-macros/tests/integration_tests.rs +++ b/gts-macros/tests/integration_tests.rs @@ -950,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( @@ -980,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(); @@ -1048,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/lib.rs b/gts/src/lib.rs index a1bbc90..dd41fb3 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -8,6 +8,7 @@ pub mod schema_cast; pub mod schema_compat; pub mod schema_modifiers; pub mod schema_narrow; +pub mod schema_refs; pub mod schema_traits; pub mod store; #[doc(hidden)] @@ -33,5 +34,8 @@ pub use schema::{ pub use schema_cast::{GtsEntityCastResult, SchemaCastError}; pub use schema_narrow::{NarrowError, try_narrow}; pub use schema_traits::{GtsTraitsSchema, inline_traits_schema_of}; -pub use store::{GtsReader, GtsStore, GtsStoreQueryResult, StoreError}; +pub use store::{ + GtsReader, GtsStore, GtsStoreQueryResult, ResolveSchemaRefsError, ResolvedTypeSchema, + StoreError, +}; pub use x_gts_ref::{XGtsRefValidationError, XGtsRefValidator}; diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 7b201f8..d669964 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 { @@ -695,6 +699,71 @@ impl GtsOps { } } + /// OP#13 **entity-level** check for `/validate-entity`, stricter than the + /// `/validate-type-schema` conformance run (`GtsStore::validate_schema_traits`). + /// 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). + for ts in &traits.resolved_trait_schemas { + if let Some(obj) = ts.as_object() + && obj.get("additionalProperties") != Some(&Value::Bool(false)) + { + return Err("Entity trait schema must set additionalProperties: false \ + to be a valid standalone entity" + .to_owned()); + } + } + + Ok(()) + } + 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, @@ -733,16 +802,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, }; } @@ -3697,4 +3765,94 @@ 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" + ); + } } diff --git a/gts/src/schema_modifiers.rs b/gts/src/schema_modifiers.rs index d171783..d867207 100644 --- a/gts/src/schema_modifiers.rs +++ b/gts/src/schema_modifiers.rs @@ -543,7 +543,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 +574,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 +605,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 +651,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 +693,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 +728,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 +757,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 +790,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 +835,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 +900,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 +929,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..aefbe5a --- /dev/null +++ b/gts/src/schema_refs.rs @@ -0,0 +1,150 @@ +use serde_json::Value; +use std::collections::BTreeSet; + +use crate::gts::{GTS_URI_PREFIX, GtsTypeId}; +use crate::store::StoreError; + +/// 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. A +/// bare id `$ref` (no `gts://`) is normalized the same way. +/// +/// Each external `$ref` MUST resolve to a valid GTS **type** id (a valid +/// identifier ending with `~`); a malformed ref is rejected up front rather +/// than surfacing later as a failed lookup. +/// +/// 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. +/// +/// # Errors +/// `StoreError::InvalidRef` if an external `$ref` is not a valid GTS type id. +pub fn extract_gts_refs(schema: &Value) -> Result, StoreError> { + let mut refs = BTreeSet::new(); + collect_gts_refs(schema, 0, &mut refs)?; + Ok(refs) +} + +fn collect_gts_refs( + value: &Value, + depth: usize, + out: &mut BTreeSet, +) -> Result<(), StoreError> { + const MAX_REF_SCAN_DEPTH: usize = 64; + if depth > MAX_REF_SCAN_DEPTH { + return Ok(()); + } + + match value { + Value::Object(map) => { + if let Some(Value::String(ref_uri)) = map.get("$ref") + && !ref_uri.starts_with('#') + { + let canonical = ref_uri.strip_prefix(GTS_URI_PREFIX).unwrap_or(ref_uri); + let id = canonical.split_once('#').map_or(canonical, |(id, _)| id); + if GtsTypeId::try_new(id).is_err() { + return Err(StoreError::InvalidRef(format!( + "'{ref_uri}' must reference a GTS type id (a valid identifier ending with '~')" + ))); + } + out.insert(id.to_owned()); + } + for v in map.values() { + collect_gts_refs(v, depth + 1, out)?; + } + } + Value::Array(items) => { + for v in items { + collect_gts_refs(v, 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~"}, + // bare id ref (no scheme) - normalized the same way + "b": {"$ref": "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_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(StoreError::InvalidRef(_)) + )); + + let garbage_ref = json!({ + "type": "object", + "properties": {"a": {"$ref": "gts://not a gts id"}} + }); + assert!(matches!( + extract_gts_refs(&garbage_ref), + Err(StoreError::InvalidRef(_)) + )); + } +} diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index 7bf08d4..68eb130 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -10,8 +10,9 @@ //! 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`). @@ -21,8 +22,8 @@ //! - 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. @@ -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 prop_schema.as_object().is_some_and(|o| { + o.get("type") == Some(&Value::String("object".to_owned())) + && o.contains_key("properties") + }) { + // Property is present and is an object type with sub-properties — + // recurse to materialize nested const/default values. If the input + // value is a non-object (e.g. a string where the schema expects an + // object), the recursion will produce a materialized object that + // replaces the original value; JSON Schema validation will catch the + // type mismatch later, so this is intentional. + let nested = materialize_traits_recursive( + prop_schema, + result.get(name.as_str()).unwrap_or(&Value::Null), + depth + 1, + ); + result.insert(name.clone(), nested); } } @@ -679,11 +835,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 +850,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)" )); } } @@ -818,6 +977,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 +1866,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 +1904,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 +1954,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 +1967,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 resolve_schema_refs_checked; 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..5498a41 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +use std::fmt; use std::sync::{Arc, RwLock}; use thiserror::Error; @@ -90,6 +91,23 @@ pub enum StoreError { InvalidRef(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveSchemaRefsError { + CircularRef, + UnresolvedRefs(Vec), +} + +impl fmt::Display for ResolveSchemaRefsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CircularRef => write!(f, "circular $ref detected"), + Self::UnresolvedRefs(refs) => write!(f, "unresolved $ref(s): {}", refs.join(", ")), + } + } +} + +impl std::error::Error for ResolveSchemaRefsError {} + pub trait GtsReader: Send { fn iter(&mut self) -> Box + '_>; fn read_by_id(&self, entity_id: &str) -> Option; @@ -105,22 +123,57 @@ 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`. +#[derive(Debug, Clone)] +pub struct ResolvedTypeSchema { + /// Type body with all `#/` and `gts://` `$ref`s inlined. + pub resolved_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, + /// `true` when the type declares `x-gts-abstract: true`. + pub is_abstract: bool, +} + pub struct GtsStore { by_id: HashMap, reader: Option>, } +impl Default for GtsStore { + fn default() -> Self { + Self::new() + } +} + 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 } @@ -203,11 +256,13 @@ impl GtsStore { self.by_id.iter() } - /// Resolve all `$ref` references in a JSON Schema by inlining the referenced schemas. + /// Best-effort `$ref` resolution for a JSON Schema. /// - /// 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. + /// This method recursively traverses the schema and replaces resolvable + /// `gts://` `$ref`s with the actual schema content from the store. External + /// refs that cannot be resolved are preserved in the returned value rather + /// than removed. Use [`Self::resolve_schema_refs_checked`] when unresolved + /// refs must be treated as an error. /// /// # Arguments /// @@ -215,7 +270,8 @@ impl GtsStore { /// /// # Returns /// - /// A new `serde_json::Value` with all `$ref` references resolved and inlined. + /// A new `serde_json::Value` with all resolvable `$ref` references inlined + /// and unresolved refs left intact. /// /// # Example /// @@ -229,17 +285,17 @@ impl GtsStore { /// /// // Resolve references /// let inlined = store.resolve_schema_refs(&child_schema_with_ref); - /// assert!(!inlined.to_string().contains("$ref")); /// ``` #[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) + let mut unresolved_refs = Vec::new(); + self.resolve_schema_refs_inner(schema, &mut visited, &mut cycle_found, &mut unresolved_refs) } - /// Like [`resolve_schema_refs`] but returns an error if a circular `$ref` - /// is detected during resolution. + /// Like [`resolve_schema_refs`] 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 @@ -247,13 +303,28 @@ impl GtsStore { /// 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 { + /// + /// # Errors + /// Returns [`ResolveSchemaRefsError::UnresolvedRefs`] if any external + /// `$ref` cannot be resolved, or [`ResolveSchemaRefsError::CircularRef`] + /// if a circular `$ref` is detected. + pub 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); + let mut unresolved_refs = Vec::new(); + let resolved = self.resolve_schema_refs_inner( + schema, + &mut visited, + &mut cycle_found, + &mut unresolved_refs, + ); if cycle_found { - Err("circular $ref detected".to_owned()) + Err(ResolveSchemaRefsError::CircularRef) + } else if !unresolved_refs.is_empty() { + Err(ResolveSchemaRefsError::UnresolvedRefs(unresolved_refs)) } else { Ok(resolved) } @@ -265,7 +336,7 @@ impl GtsStore { schema: &Value, visited: &mut std::collections::HashSet, cycle_found: &mut bool, - strict_cycles: bool, + unresolved_refs: &mut Vec, ) -> Value { // Recursively resolve $ref references in the schema match schema { @@ -290,7 +361,7 @@ impl GtsStore { v, visited, cycle_found, - strict_cycles, + unresolved_refs, ), ); } @@ -301,99 +372,124 @@ impl GtsStore { // 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 — drop it to avoid infinite loop + // 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 { - if k != "$ref" { - new_map.insert( - k.clone(), + new_map.insert( + k.clone(), + if k == "$ref" { + v.clone() + } else { self.resolve_schema_refs_inner( v, visited, cycle_found, - strict_cycles, - ), - ); - } - } - if new_map.is_empty() { - return schema.clone(); + unresolved_refs, + ) + }, + ); } return Value::Object(new_map); } // Try to resolve the reference using canonical ID - if let Some(entity) = self.by_id.get(canonical_ref) + if let Some(entity) = self.by_id.get(lookup_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 { + let target_content = match pointer_fragment { + Some("") => Some(&entity.content), + Some(pointer) => entity.content.pointer(pointer), + None if canonical_ref.contains('#') => None, + None => Some(&entity.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_schema_refs_inner( + target_content, + visited, + cycle_found, + unresolved_refs, + ); 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"); - } + // 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; - } + // 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, - ), - ); + // 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, + unresolved_refs, + ), + ); + } } + return Value::Object(merged); } - return Value::Object(merged); } } - // If we can't resolve, remove the $ref to avoid "relative URL" errors - // and keep other properties + 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 { - if k != "$ref" { - new_map.insert( - k.clone(), + new_map.insert( + k.clone(), + if k == "$ref" { + v.clone() + } else { self.resolve_schema_refs_inner( v, visited, cycle_found, - strict_cycles, - ), - ); - } - } - if !new_map.is_empty() { - return Value::Object(new_map); + unresolved_refs, + ) + }, + ); } - return schema.clone(); + return Value::Object(new_map); } // Special handling for allOf arrays - merge $ref resolved schemas @@ -407,7 +503,7 @@ impl GtsStore { item, visited, cycle_found, - strict_cycles, + unresolved_refs, ); match resolved_item { @@ -472,14 +568,16 @@ impl GtsStore { for (k, v) in map { new_map.insert( k.clone(), - self.resolve_schema_refs_inner(v, visited, cycle_found, strict_cycles), + self.resolve_schema_refs_inner(v, visited, cycle_found, unresolved_refs), ); } Value::Object(new_map) } Value::Array(arr) => Value::Array( arr.iter() - .map(|v| self.resolve_schema_refs_inner(v, visited, cycle_found, strict_cycles)) + .map(|v| { + self.resolve_schema_refs_inner(v, visited, cycle_found, unresolved_refs) + }) .collect(), ), _ => schema.clone(), @@ -834,27 +932,66 @@ impl GtsStore { /// flattening which would drop `x-gts-*` keys), resolves `$ref` inside /// collected trait schemas, then validates. /// + /// Abstract types short-circuit before the chain is even walked — they are + /// templates, not deployable entities, so trait completeness is not enforced + /// (descendants close required traits). + /// /// # 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) - .map_err(|e| StoreError::ValidationError(format!("Invalid GTS ID: {e}")))?; + // Abstract types are templates — trait completeness is not enforced + // (descendants close required traits). + if self + .get(gts_id) + .is_some_and(|e| Self::content_is_abstract(&e.content)) + { + return Ok(()); + } + self.effective_traits(gts_id)? + .validate(true) + .map_err(|errors| Self::wrap_trait_error(gts_id, &errors)) + } + + /// `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)) + } + + /// 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). + /// + /// 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. Shared by [`Self::validate_schema_traits`] and + /// [`Self::validate_and_resolve_type_schema`]. + /// + /// # Errors + /// `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,155 +1010,161 @@ 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}")) + 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| { - StoreError::ValidationError(format!( - "Schema '{}' trait validation failed: {}", - gts_id, - errors.join("; ") - )) - }) + )) } - /// OP#13 entity-level check: ensures the effective trait schema is "closed". + /// Validate a type and return its fully-resolved [`ResolvedTypeSchema`] in a + /// single call. Every type it depends on (its `$id`-chain ancestors and + /// the targets of its `gts://` `$ref`s) must already be registered. /// - /// 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(()); + /// Runs `validate_schema` (meta-schema + `x-gts-ref`) and + /// `validate_schema_chain` (OP#12), then a single resolve pass: the body is + /// inlined and the effective traits schema/values are built **exactly once**. + /// When the leaf is not abstract, the built artifacts validate themselves + /// (OP#13) before being returned — no rebuild. Abstract types skip the + /// completeness check (descendants close required traits) but still produce + /// artifacts. + /// + /// Uncached: a consumer that calls this repeatedly for the same `type_id` + /// should cache the result (safe forever — versioned ids are immutable). + /// + /// # 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_and_resolve_type_schema( + &mut self, + type_id: &str, + ) -> Result { + self.validate_schema(type_id)?; + self.validate_schema_chain(type_id)?; + + let content = self.get_schema_content(type_id)?; + let resolved_schema = self + .resolve_schema_refs_checked(&content) + .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; + + // Abstract types still produce artifacts but skip the completeness check. + let is_abstract = Self::content_is_abstract(&content); + let traits = self.effective_traits(type_id)?; + + if !is_abstract { + traits + .validate(true) + .map_err(|errors| Self::wrap_trait_error(type_id, &errors))?; } - let segments = &gid.segments(); + Ok(ResolvedTypeSchema { + resolved_schema, + effective_traits: traits.values, + effective_traits_schema: traits.schema, + is_abstract, + }) + } - let mut trait_schemas: Vec = Vec::new(); - let mut has_trait_values = false; + /// Validate a caller-supplied instance payload against `type_id`'s schema. + /// + /// 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" + ))); + } - 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 + .resolve_schema_refs_checked(&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, ""); + if !xref_errors.is_empty() { + let msgs: Vec = xref_errors + .iter() + .map(|e| { + if e.field_path.is_empty() { + e.reason.clone() + } else { + format!("{}: {}", e.field_path, e.reason) } - } - } + }) + .collect(); + return Err(StoreError::ValidationError(format!( + "x-gts-ref validation failed: {}", + msgs.join("; ") + ))); } Ok(()) @@ -1054,7 +1197,7 @@ impl GtsStore { let schema = self.get_schema_content(&type_id)?; // Check x-gts-abstract: abstract types cannot have direct instances. - if schema.get(crate::schema_modifiers::X_GTS_ABSTRACT) == Some(&Value::Bool(true)) { + if Self::content_is_abstract(&schema) { return Err(StoreError::ValidationError(format!( "type '{type_id}' is abstract and cannot have direct instances" ))); @@ -1066,9 +1209,12 @@ 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); + // Resolve references before JSON Schema compilation. Missing external + // refs are fatal here; otherwise validation could silently ignore part + // of the intended schema. + let schema_with_internal_refs_resolved = self + .resolve_schema_refs_checked(&schema) + .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; // Remove x-gts-ref fields before jsonschema validation. // x-gts-ref is a GTS extension unknown to the jsonschema crate; leaving it diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index 9509bfc..594fcf9 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -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" @@ -111,7 +111,7 @@ fn test_gts_store_register_schema_invalid_id() { #[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,7 +130,7 @@ fn test_gts_store_get_schema_content() { #[test] fn test_gts_store_get_schema_content_not_found() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let result = store.get_schema_content("nonexistent~"); assert!(result.is_err()); @@ -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 { @@ -289,7 +289,7 @@ fn test_store_error_display() { #[test] fn test_gts_store_cast() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register schemas let schema_v1 = json!({ @@ -352,7 +352,7 @@ fn test_gts_store_cast() { #[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 +360,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 +388,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 +427,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 +455,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 +480,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 +521,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 +564,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!({ @@ -599,7 +599,7 @@ fn test_gts_store_validate_instance_no_schema() { #[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 +613,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 +628,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 +658,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!({ @@ -702,14 +702,14 @@ fn test_gts_store_cast_entity_without_schema() { #[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!({ @@ -771,7 +771,7 @@ fn test_gts_store_validate_instance_with_refs() { #[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 +814,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 +837,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 +858,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~", @@ -919,7 +919,7 @@ fn test_gts_store_cast_with_validation() { #[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 +951,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 +981,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!({ @@ -1041,7 +1041,7 @@ fn test_gts_store_error_variants() { #[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 +1083,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!({ @@ -1124,7 +1124,7 @@ fn test_gts_store_cast_missing_source_schema() { #[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 +1157,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~", @@ -1233,7 +1233,7 @@ fn test_gts_store_validate_with_nested_refs() { #[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 +1256,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~", @@ -1315,7 +1315,7 @@ fn test_gts_store_cast_backward_incompatible() { #[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 +1338,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 +1377,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 +1422,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 +1443,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~", @@ -1496,7 +1496,7 @@ fn test_gts_store_validate_with_complex_schema() { #[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 +1537,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 +1553,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 +1561,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 +1579,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~", @@ -1623,7 +1623,7 @@ fn test_gts_store_cast_same_version() { #[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 +1667,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 +1694,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 +1735,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 +1756,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 +1769,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 +1804,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] @@ -1828,7 +1833,7 @@ fn test_gts_store_query_result_serialization_with_error() { #[test] fn test_gts_store_resolve_schema_refs_with_merge() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register base schema let base_schema = json!({ @@ -1882,12 +1887,15 @@ fn test_gts_store_resolve_schema_refs_with_merge() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - assert!(result.is_ok() || result.is_err()); + assert!( + result.is_ok(), + "resolvable sibling $ref should validate, got: {result:?}" + ); } #[test] fn test_gts_store_resolve_schema_refs_with_unresolvable_and_properties() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Schema with unresolvable $ref but with other properties let schema = json!({ @@ -1926,12 +1934,18 @@ fn test_gts_store_resolve_schema_refs_with_unresolvable_and_properties() { store.register(entity).expect("test"); let result = store.validate_instance("gts.vendor.package.namespace.type.v1.0"); - 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] 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!({ @@ -1971,7 +1985,7 @@ fn test_gts_store_cast_from_schema_entity() { #[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 +2032,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 +2065,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 +2130,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 +2140,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 +2149,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,7 +2158,7 @@ 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"); @@ -2153,7 +2167,7 @@ fn test_gts_store_validate_instance_invalid_gts_id() { #[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 +2264,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 +2293,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 +2320,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); @@ -2405,7 +2419,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!({ @@ -2428,7 +2442,7 @@ fn test_validate_schema_integration() { #[test] fn test_resolve_schema_refs_with_gts_uri_prefix() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Register base schema let base_schema = json!({ @@ -2642,7 +2656,7 @@ 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 mut store = GtsStore::new(); let result = store.validate_schema_x_gts_refs("gts.vendor.package.namespace.type.v1.0"); assert!(result.is_err()); @@ -2658,7 +2672,7 @@ 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 mut store = GtsStore::new(); let result = store.validate_schema_x_gts_refs("gts.vendor.package.namespace.type.v1.0~"); assert!(result.is_err()); @@ -2673,7 +2687,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 @@ -2710,7 +2724,7 @@ 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); + let mut store = GtsStore::new(); // Create a schema with invalid x-gts-ref let schema_content = json!({ @@ -2742,7 +2756,7 @@ 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 mut store = GtsStore::new(); let result = store.validate_schema("gts.vendor.package.namespace.type.v1.0"); assert!(result.is_err()); @@ -2758,7 +2772,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!({ @@ -2796,7 +2810,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"]); @@ -2823,7 +2837,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 +2885,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 +2937,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 +2991,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 @@ -3029,7 +3043,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#", @@ -3053,7 +3067,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!({ @@ -3099,7 +3113,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~", @@ -3144,7 +3158,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 +3206,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 +3244,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 +3282,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 +3323,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 +3394,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 +3459,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~", @@ -3493,7 +3507,7 @@ fn test_op12_property_disabled_fails() { #[test] fn test_op12_derived_loosens_additional_properties_to_true() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Base schema with additionalProperties: false let base = json!({ @@ -3544,7 +3558,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 +3598,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 +3633,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 +3668,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 +3703,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 +3742,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~", @@ -3776,7 +3790,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~", @@ -3814,7 +3828,7 @@ fn test_op13_traits_defaults_fill_passes() { #[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~", @@ -3854,73 +3868,9 @@ fn test_op13_traits_missing_required_fails() { 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); - - let base = 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"}} - }); - 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); - - let base = 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"}} - }); - store - .register_schema("gts.x.test13.conc.base.v1~", &base) - .expect("register concrete base"); - - 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" - ); -} - #[test] fn test_op13_traits_wrong_type_fails() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test13.wt.base.v1~", @@ -3960,7 +3910,7 @@ fn test_op13_traits_wrong_type_fails() { #[test] fn test_op13_traits_no_traits_schema_passes() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); let base = json!({ "$id": "gts://gts.x.test13.nt.base.v1~", @@ -3994,7 +3944,7 @@ fn test_op13_traits_no_traits_schema_passes() { #[test] fn test_store_resolve_schema_refs_empty_schema() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let empty_schema = json!({}); let resolved = store.resolve_schema_refs(&empty_schema); assert_eq!(resolved, empty_schema); @@ -4002,7 +3952,7 @@ fn test_store_resolve_schema_refs_empty_schema() { #[test] fn test_store_resolve_schema_refs_null_value() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let null_schema = Value::Null; let resolved = store.resolve_schema_refs(&null_schema); assert_eq!(resolved, null_schema); @@ -4010,7 +3960,7 @@ fn test_store_resolve_schema_refs_null_value() { #[test] fn test_store_resolve_schema_refs_array_value() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let array_schema = json!([1, 2, 3]); let resolved = store.resolve_schema_refs(&array_schema); assert_eq!(resolved, array_schema); @@ -4018,7 +3968,7 @@ fn test_store_resolve_schema_refs_array_value() { #[test] fn test_store_resolve_schema_refs_primitive_value() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let string_schema = json!("test"); let resolved = store.resolve_schema_refs(&string_schema); assert_eq!(resolved, string_schema); @@ -4026,7 +3976,7 @@ fn test_store_resolve_schema_refs_primitive_value() { #[test] fn test_store_resolve_schema_refs_nested_objects() { - let store = GtsStore::new(None); + let store = GtsStore::new(); let nested = json!({ "outer": { "inner": { @@ -4038,9 +3988,171 @@ fn test_store_resolve_schema_refs_nested_objects() { assert_eq!(resolved, nested); } +#[test] +fn test_store_resolve_schema_refs_inlines_exact_gts_uri_ref() { + let mut store = GtsStore::new(); + let target_schema = 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"] + }); + store + .register_schema("gts.x.core.events.type.v1~", &target_schema) + .expect("register target schema"); + + 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"}, + "event": {"type": "string"} + }, + "required": ["id"] + }) + ); +} + +#[test] +fn test_store_resolve_schema_refs_inlines_nested_gts_uri_ref() { + let mut store = GtsStore::new(); + let target_schema = json!({ + "$id": "gts://gts.x.core.events.detail.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "code": {"type": "string"} + } + }); + store + .register_schema("gts.x.core.events.detail.v1~", &target_schema) + .expect("register target schema"); + + let resolved = store.resolve_schema_refs(&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_store_resolve_schema_refs_keeps_unresolved_bare_gts_uri_ref() { + let store = GtsStore::new(); + let schema = json!({ + "$ref": "gts://gts.x.core.events.missing.v1~" + }); + + let resolved = store.resolve_schema_refs(&schema); + + assert_eq!(resolved, schema); +} + +#[test] +fn test_store_resolve_schema_refs_keeps_unresolved_gts_uri_ref_with_siblings() { + let store = GtsStore::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 = store.resolve_schema_refs(&schema); + + assert_eq!(resolved, schema); + assert_eq!( + resolved["properties"]["event"]["$ref"], + "gts://gts.x.core.events.missing.v1~" + ); +} + +#[test] +fn test_store_resolve_schema_refs_checked_errors_on_unresolved_gts_uri_ref() { + let store = GtsStore::new(); + let schema = json!({ + "type": "object", + "properties": { + "event": { + "$ref": "gts://gts.x.core.events.missing.v1~", + "description": "strict mode should reject this" + } + } + }); + + let err = store + .resolve_schema_refs_checked(&schema) + .expect_err("missing external ref should fail checked resolution"); + + assert_eq!( + err, + ResolveSchemaRefsError::UnresolvedRefs(vec![ + "gts://gts.x.core.events.missing.v1~".to_owned() + ]) + ); +} + +#[test] +fn test_store_resolve_schema_refs_uses_exact_gts_uri_lookup_without_minor_fallback() { + let mut store = GtsStore::new(); + let target_schema = 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"} + } + }); + store + .register_schema("gts.x.core.events.type.v1.0~", &target_schema) + .expect("register target schema"); + + let schema = json!({ + "$ref": "gts://gts.x.core.events.type.v1~" + }); + let resolved = store.resolve_schema_refs(&schema); + + assert_eq!( + resolved, schema, + "resolve_schema_refs should not resolve v1~ by matching a stored v1.0~ schema" + ); + + let err = store + .resolve_schema_refs_checked(&schema) + .expect_err("checked resolution should reject the unresolved v1~ ref"); + assert_eq!( + err, + ResolveSchemaRefsError::UnresolvedRefs(vec!["gts://gts.x.core.events.type.v1~".to_owned()]) + ); +} + #[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 +4174,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 +4183,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 +4203,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 +4230,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~"); @@ -4151,7 +4263,7 @@ fn test_store_error_variants() { #[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}"), @@ -4170,7 +4282,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!({ @@ -4244,7 +4356,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!({ @@ -4289,7 +4401,7 @@ fn test_op13_traits_ref_to_nonexistent_schema() { #[test] fn test_op13_circular_ref_does_not_hang() { - let mut store = GtsStore::new(None); + let mut store = GtsStore::new(); // Schema A refs schema B, schema B refs schema A — circular let schema_a = json!({ @@ -4324,6 +4436,11 @@ fn test_op13_circular_ref_does_not_hang() { 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"); + + let err = store + .resolve_schema_refs_checked(&schema_a) + .expect_err("checked resolution should reject circular refs"); + assert_eq!(err, ResolveSchemaRefsError::CircularRef); } #[test] @@ -4332,7 +4449,7 @@ fn test_resolve_schema_refs_checked_allows_duplicate_ref_in_allof() { // 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 mut store = GtsStore::new(); let trait_schema = json!({ "$id": "gts://gts.x.test.dup.trait.v1~", @@ -4371,7 +4488,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~", @@ -4429,3 +4546,351 @@ fn test_op13_redeclared_default_in_mid_allowed() { "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" + ); +} + +#[test] +fn test_resolve_returns_artifacts() { + 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"} + }} + }), + ) + .unwrap(); + + let rt = store + .validate_and_resolve_type_schema("gts.x.rs.tr.base.v1~") + .unwrap(); + assert_eq!(rt.effective_traits["tier"], "standard"); + assert!(!rt.is_abstract); + assert!(rt.resolved_schema.is_object()); + assert_eq!( + rt.effective_traits_schema["$schema"], + "http://json-schema.org/draft-07/schema#" + ); +} + +#[test] +fn test_effective_projections() { + 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_and_resolve_type_schema("gts.x.ep.tr.base.v1~") + .unwrap(); + assert!(rt.resolved_schema.is_object()); + assert_eq!(rt.effective_traits["retention"], "P30D"); + assert_eq!( + rt.effective_traits_schema["$schema"], + "http://json-schema.org/draft-07/schema#" + ); +} + +#[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_traits("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_traits("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_traits("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_traits("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_traits("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_traits("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_traits(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_traits("gts.x.cd.tr.base.v1~x.cd._.bad.v1~") + .is_err(), + "ancestor enum constraint must still be enforced" + ); +} From 998249ea534195b63ec6959398b98f015781edfc Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Fri, 19 Jun 2026 00:12:41 +0300 Subject: [PATCH 3/4] fix(gts): validate resolved schemas and GTS ref fragments - Accept gts:// $ref values with supported JSON Pointer fragments while rejecting unsupported fragments. - Compile fully resolved schemas in validate_and_resolve_type_schema to catch malformed bodies deferred by raw GTS refs. - Validate relative x-gts-ref targets with GtsIdPattern so wildcard patterns resolve consistently. Signed-off-by: Aviator 5 --- gts/src/entities.rs | 3 +- gts/src/store.rs | 44 +++++++++++- gts/src/store_test.rs | 151 ++++++++++++++++++++++++++++++++++++++++++ gts/src/x_gts_ref.rs | 61 ++++++++++++++++- 4 files changed, 253 insertions(+), 6 deletions(-) diff --git a/gts/src/entities.rs b/gts/src/entities.rs index cb59665..4859569 100644 --- a/gts/src/entities.rs +++ b/gts/src/entities.rs @@ -217,7 +217,8 @@ impl GtsEntity { /// Extract IDs for a schema entity (Type Schema). /// - `gts_id`: from `$id` field (must be `gts://` URI with GTS Type Identifier) - /// - `type_id`: either the type itself (if standalone) or parent type (if chained) + /// - `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 diff --git a/gts/src/store.rs b/gts/src/store.rs index 5498a41..e263cda 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -128,7 +128,9 @@ pub struct GtsStoreQueryResult { /// 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`. +/// 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 ResolvedTypeSchema { /// Type body with all `#/` and `gts://` `$ref`s inlined. @@ -705,10 +707,30 @@ impl GtsStore { // 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() { + // A GTS `$ref` may carry a JSON Pointer fragment that + // selects 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_part, fragment) = match gts_id.split_once('#') { + Some((id, frag)) => (id, Some(frag)), + None => (gts_id, None), + }; + if crate::GtsTypeId::try_new(id_part).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}'" + (a valid identifier ending with '~'), got '{id_part}'" + ))); + } + if let Some(frag) = fragment + && !frag.is_empty() + && !frag.starts_with('/') + { + return Err(StoreError::InvalidRef(format!( + "at '{current_path}': '{ref_uri}' has an unsupported fragment \ + '#{frag}'; only an empty fragment or a '/'-prefixed JSON Pointer \ + is allowed" ))); } } @@ -1081,6 +1103,22 @@ impl GtsStore { .resolve_schema_refs_checked(&content) .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; + // Meta-validate the fully-resolved schema. `validate_schema` skips + // jsonschema compilation whenever raw `gts://` refs are present (forward + // references may be unregistered at registration time); now that every + // dependency is inlined we can compile the structure that registration + // deferred and catch malformed schema bodies 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!( + "JSON Schema validation failed for '{type_id}': {e}" + )) + })?; + // Abstract types still produce artifacts but skip the completeness check. let is_abstract = Self::content_is_abstract(&content); let traits = self.effective_traits(type_id)?; diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index 594fcf9..405e3f6 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -4894,3 +4894,154 @@ fn test_trait_schema_cross_doc_fragment_ref_does_not_break_validation() { "ancestor enum constraint must still be enforced" ); } + +#[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` 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("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("gts.vendor.package.namespace.type.v1.0~"), + Err(StoreError::InvalidRef(_)) + )); +} + +#[test] +fn test_validate_and_resolve_meta_validates_resolved_schema() { + // `validate_schema` skips jsonschema compilation when raw `gts://` refs are + // present, so a structurally malformed body slips past registration-time + // checks. `validate_and_resolve_type_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 skips compilation because of the gts:// ref. + store + .validate_schema("gts.vendor.package.namespace.type.v1.0~") + .expect("validate_schema skips compilation for gts:// schemas"); + + // But the single-pass API now compiles the resolved schema and rejects it. + assert!(matches!( + store.validate_and_resolve_type_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_and_resolve_type_schema("gts.vendor.package.namespace.type.v1.0~") + .expect("well-formed schema must validate and resolve"); +} diff --git a/gts/src/x_gts_ref.rs b/gts/src/x_gts_ref.rs index 9d20b67..ea42a91 100644 --- a/gts/src/x_gts_ref.rs +++ b/gts/src/x_gts_ref.rs @@ -416,13 +416,19 @@ impl XGtsRefValidator { 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 ), )); } @@ -618,6 +624,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(); From 8afe00155623adf86fa334b21eb341db133b55dd Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Fri, 19 Jun 2026 01:08:05 +0300 Subject: [PATCH 4/4] fix(gts): tighten schema reference handling - Surface checked resolution failures through StoreError for unresolved and circular refs. - Preserve allOf closedness from referenced schemas and keep non-object ref targets intact. - Reject over-deep ref scans and add regression coverage for chained IDs, URI validation, local pointer overlay, and referenced trait schemas. Signed-off-by: Aviator 5 --- gts-id/src/gts_id.rs | 4 + gts/src/entities.rs | 26 +++ gts/src/lib.rs | 6 +- gts/src/ops.rs | 43 +++++ gts/src/schema_compat.rs | 68 ++++++-- gts/src/schema_refs.rs | 77 +++++++-- gts/src/schema_traits.rs | 28 +++ gts/src/store.rs | 357 ++++++++++++++++++--------------------- gts/src/store_test.rs | 151 ++++++++++++----- 9 files changed, 496 insertions(+), 264 deletions(-) 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/src/entities.rs b/gts/src/entities.rs index 4859569..87bdbab 100644 --- a/gts/src/entities.rs +++ b/gts/src/entities.rs @@ -940,6 +940,32 @@ 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 diff --git a/gts/src/lib.rs b/gts/src/lib.rs index dd41fb3..09e80c2 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -33,9 +33,7 @@ pub use schema::{ }; pub use schema_cast::{GtsEntityCastResult, SchemaCastError}; pub use schema_narrow::{NarrowError, try_narrow}; +pub use schema_refs::{ExtractRefsError, extract_gts_refs}; pub use schema_traits::{GtsTraitsSchema, inline_traits_schema_of}; -pub use store::{ - GtsReader, GtsStore, GtsStoreQueryResult, ResolveSchemaRefsError, ResolvedTypeSchema, - StoreError, -}; +pub use store::{GtsReader, GtsStore, GtsStoreQueryResult, ResolvedTypeSchema, StoreError}; pub use x_gts_ref::{XGtsRefValidationError, XGtsRefValidator}; diff --git a/gts/src/ops.rs b/gts/src/ops.rs index d669964..9e3e044 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -3855,4 +3855,47 @@ mod tests { "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 allOf-merge + // must propagate `additionalProperties` so the standalone-entity check + // does not wrongly reject this valid closed referenced schema. + 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" + ); + } } 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_refs.rs b/gts/src/schema_refs.rs index aefbe5a..fcac89b 100644 --- a/gts/src/schema_refs.rs +++ b/gts/src/schema_refs.rs @@ -2,7 +2,15 @@ use serde_json::Value; use std::collections::BTreeSet; use crate::gts::{GTS_URI_PREFIX, GtsTypeId}; -use crate::store::StoreError; + +/// Failure modes of [`extract_gts_refs`]. Store-independent, like the module. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ExtractRefsError { + #[error("'{0}' must reference a GTS type id (a valid identifier ending with '~')")] + InvalidRef(String), + #[error("schema nests deeper than the maximum scan depth of {0}")] + TooDeep(usize), +} /// Direct external type references of a **raw** schema - the `$ref` /// dependency edges of this node, store-independent and pure. @@ -23,8 +31,10 @@ use crate::store::StoreError; /// the id, not from content. /// /// # Errors -/// `StoreError::InvalidRef` if an external `$ref` is not a valid GTS type id. -pub fn extract_gts_refs(schema: &Value) -> Result, StoreError> { +/// [`ExtractRefsError::InvalidRef`] if an external `$ref` is not a valid GTS +/// type id; [`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) @@ -34,10 +44,10 @@ fn collect_gts_refs( value: &Value, depth: usize, out: &mut BTreeSet, -) -> Result<(), StoreError> { +) -> Result<(), ExtractRefsError> { const MAX_REF_SCAN_DEPTH: usize = 64; if depth > MAX_REF_SCAN_DEPTH { - return Ok(()); + return Err(ExtractRefsError::TooDeep(MAX_REF_SCAN_DEPTH)); } match value { @@ -48,9 +58,7 @@ fn collect_gts_refs( let canonical = ref_uri.strip_prefix(GTS_URI_PREFIX).unwrap_or(ref_uri); let id = canonical.split_once('#').map_or(canonical, |(id, _)| id); if GtsTypeId::try_new(id).is_err() { - return Err(StoreError::InvalidRef(format!( - "'{ref_uri}' must reference a GTS type id (a valid identifier ending with '~')" - ))); + return Err(ExtractRefsError::InvalidRef(ref_uri.clone())); } out.insert(id.to_owned()); } @@ -135,7 +143,7 @@ mod tests { }); assert!(matches!( extract_gts_refs(&instance_ref), - Err(StoreError::InvalidRef(_)) + Err(ExtractRefsError::InvalidRef(_)) )); let garbage_ref = json!({ @@ -144,7 +152,56 @@ mod tests { }); assert!(matches!( extract_gts_refs(&garbage_ref), - Err(StoreError::InvalidRef(_)) + Err(ExtractRefsError::InvalidRef(_)) )); } + + #[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.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_traits.rs b/gts/src/schema_traits.rs index 68eb130..6e1e52b 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -875,6 +875,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![( diff --git a/gts/src/store.rs b/gts/src/store.rs index e263cda..b5a5b3f 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -1,13 +1,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use std::fmt; 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; +use crate::schema_compat::merge_additional_properties_constraint; /// Custom retriever for resolving gts:// URI scheme references in JSON Schema validation struct GtsRetriever { @@ -69,45 +69,24 @@ 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), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ResolveSchemaRefsError { + #[error("Circular $ref detected")] CircularRef, + #[error("Unresolved $ref(s): {}", .0.join(", "))] UnresolvedRefs(Vec), } -impl fmt::Display for ResolveSchemaRefsError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::CircularRef => write!(f, "circular $ref detected"), - Self::UnresolvedRefs(refs) => write!(f, "unresolved $ref(s): {}", refs.join(", ")), - } - } -} - -impl std::error::Error for ResolveSchemaRefsError {} - pub trait GtsReader: Send { fn iter(&mut self) -> Box + '_>; fn read_by_id(&self, entity_id: &str) -> Option; @@ -196,7 +175,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(()) } @@ -204,13 +185,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, @@ -243,15 +226,58 @@ 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 { @@ -307,13 +333,10 @@ impl GtsStore { /// manual aggregation across an `$id` chain is allowed. /// /// # Errors - /// Returns [`ResolveSchemaRefsError::UnresolvedRefs`] if any external - /// `$ref` cannot be resolved, or [`ResolveSchemaRefsError::CircularRef`] - /// if a circular `$ref` is detected. - pub fn resolve_schema_refs_checked( - &self, - schema: &Value, - ) -> Result { + /// Returns [`StoreError::UnresolvedRefs`] if any external `$ref` cannot be + /// resolved, or [`StoreError::CircularRef`] if a circular `$ref` is + /// detected. + pub fn resolve_schema_refs_checked(&self, schema: &Value) -> Result { let mut visited = std::collections::HashSet::new(); let mut cycle_found = false; let mut unresolved_refs = Vec::new(); @@ -324,9 +347,9 @@ impl GtsStore { &mut unresolved_refs, ); if cycle_found { - Err(ResolveSchemaRefsError::CircularRef) + Err(StoreError::CircularRef) } else if !unresolved_refs.is_empty() { - Err(ResolveSchemaRefsError::UnresolvedRefs(unresolved_refs)) + Err(StoreError::UnresolvedRefs(unresolved_refs)) } else { Ok(resolved) } @@ -448,23 +471,30 @@ impl GtsStore { 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, - unresolved_refs, - ), - ); + // Otherwise, merge the resolved schema with the + // sibling keywords. + match resolved { + Value::Object(resolved_map) => { + 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, + unresolved_refs, + ), + ); + } } + return Value::Object(merged); } - 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, } } } @@ -499,6 +529,10 @@ impl GtsStore { let mut resolved_all_of = Vec::new(); let mut merged_properties = serde_json::Map::new(); let mut merged_required: Vec = Vec::new(); + // Closedness can live inside a referenced trait schema, so + // carry `additionalProperties` out of the merged items + // rather than dropping it. + let mut merged_additional_properties: Option = None; for item in all_of_array { let resolved_item = self.resolve_schema_refs_inner( @@ -532,6 +566,12 @@ impl GtsStore { } } } + if let Some(ap) = item_map.get("additionalProperties") { + merge_additional_properties_constraint( + &mut merged_additional_properties, + ap, + ); + } } } _ => resolved_all_of.push(resolved_item), @@ -552,6 +592,15 @@ impl GtsStore { // Add merged properties and required fields merged_schema .insert("properties".to_owned(), Value::Object(merged_properties)); + if let Some(ap) = merged_schema.get("additionalProperties").cloned() { + merge_additional_properties_constraint( + &mut merged_additional_properties, + &ap, + ); + } + if let Some(ap) = merged_additional_properties { + merged_schema.insert("additionalProperties".to_owned(), ap); + } if !merged_required.is_empty() { merged_schema.insert( "required".to_owned(), @@ -634,46 +683,40 @@ 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 '~')" - ))); + /// 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("; ") + ))) + } - 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" - ))); - } + fn validate_schema_x_gts_refs(&mut self, gts_id: &str) -> Result<(), StoreError> { + let schema_content = self.get_schema_content(gts_id)?; tracing::info!("Validating schema x-gts-ref fields for {}", gts_id); // Validate x-gts-ref constraints in the schema let validator = crate::x_gts_ref::XGtsRefValidator::new(); - let x_gts_ref_errors = validator.validate_schema(&schema_entity.content, "", None); + let x_gts_ref_errors = validator.validate_schema(&schema_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(()) + Self::check_x_gts_ref_errors(&x_gts_ref_errors) } /// Validates all `$ref` values in a schema. @@ -772,23 +815,7 @@ impl GtsStore { /// # 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(); + 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" @@ -1087,6 +1114,11 @@ impl GtsStore { /// Uncached: a consumer that calls this repeatedly for the same `type_id` /// should cache the result (safe forever — versioned ids are immutable). /// + /// External-consumer convenience that bundles validation with resolution + /// (and meta-validates the inlined body). [`crate::ops::GtsOps`] keeps the + /// granular validators for stage-specific error context; keep the two paths + /// in sync when changing validation semantics. + /// /// # Errors /// `StoreError::ValidationError` if any validation stage fails or a /// dependency is missing from the store; `StoreError::SchemaNotFound` if the @@ -1188,22 +1220,7 @@ impl GtsStore { let xref = crate::x_gts_ref::XGtsRefValidator::new(); let xref_errors = xref.validate_instance(payload, &resolved_schema, ""); - if !xref_errors.is_empty() { - let msgs: Vec = xref_errors - .iter() - .map(|e| { - if e.field_path.is_empty() { - e.reason.clone() - } else { - format!("{}: {}", e.field_path, e.reason) - } - }) - .collect(); - return Err(StoreError::ValidationError(format!( - "x-gts-ref validation failed: {}", - msgs.join("; ") - ))); - } + Self::check_x_gts_ref_errors(&xref_errors)?; Ok(()) } @@ -1213,23 +1230,14 @@ 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 obj = self.get_instance_entity(instance_id)?; let type_id = obj .type_id .as_ref() - .ok_or_else(|| StoreError::SchemaForInstanceNotFound(lookup_id.clone()))? + .ok_or_else(|| { + StoreError::InvalidEntity(format!("Instance '{instance_id}' has no type_id")) + })? .clone(); let schema = self.get_schema_content(&type_id)?; @@ -1297,21 +1305,7 @@ impl GtsStore { 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)); - } + Self::check_x_gts_ref_errors(&x_gts_ref_errors)?; Ok(()) } @@ -1322,50 +1316,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 to_schema = self - .get(target_type_id) - .ok_or_else(|| StoreError::ObjectNotFound(target_type_id.to_owned()))? - .clone(); + 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(); - // 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 405e3f6..c9ea568 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -104,8 +104,8 @@ 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"), } } @@ -131,12 +131,12 @@ fn test_gts_store_get_schema_content() { #[test] fn test_gts_store_get_schema_content_not_found() { let mut store = GtsStore::new(); - let result = store.get_schema_content("nonexistent~"); + 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"), } @@ -272,16 +272,13 @@ 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")); } @@ -1032,10 +1029,10 @@ 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()); } @@ -1809,7 +1806,7 @@ fn test_gts_store_validate_with_unresolvable_ref() { result .unwrap_err() .to_string() - .contains("unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") + .contains("Unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") ); } @@ -1939,7 +1936,7 @@ fn test_gts_store_resolve_schema_refs_with_unresolvable_and_properties() { result .unwrap_err() .to_string() - .contains("unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") + .contains("Unresolved $ref(s): gts://gts.vendor.package.namespace.nonexistent.v1.0~") ); } @@ -2662,8 +2659,7 @@ fn test_validate_schema_x_gts_refs_non_schema_id() { 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"), } @@ -2762,8 +2758,7 @@ fn test_validate_schema_non_schema_id() { 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"), } @@ -3032,10 +3027,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"), } } @@ -3506,7 +3501,7 @@ fn test_op12_property_disabled_fails() { } #[test] -fn test_op12_derived_loosens_additional_properties_to_true() { +fn test_op12_direct_derived_loosens_additional_properties_to_true() { let mut store = GtsStore::new(); // Base schema with additionalProperties: false @@ -3523,14 +3518,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 @@ -3544,6 +3539,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 @@ -4108,12 +4147,11 @@ fn test_store_resolve_schema_refs_checked_errors_on_unresolved_gts_uri_ref() { .resolve_schema_refs_checked(&schema) .expect_err("missing external ref should fail checked resolution"); - assert_eq!( - err, - ResolveSchemaRefsError::UnresolvedRefs(vec![ - "gts://gts.x.core.events.missing.v1~".to_owned() - ]) - ); + assert!(matches!( + &err, + StoreError::UnresolvedRefs(refs) + if refs == &["gts://gts.x.core.events.missing.v1~".to_owned()] + )); } #[test] @@ -4144,10 +4182,11 @@ fn test_store_resolve_schema_refs_uses_exact_gts_uri_lookup_without_minor_fallba let err = store .resolve_schema_refs_checked(&schema) .expect_err("checked resolution should reject the unresolved v1~ ref"); - assert_eq!( - err, - ResolveSchemaRefsError::UnresolvedRefs(vec!["gts://gts.x.core.events.type.v1~".to_owned()]) - ); + assert!(matches!( + &err, + StoreError::UnresolvedRefs(refs) + if refs == &["gts://gts.x.core.events.type.v1~".to_owned()] + )); } #[test] @@ -4240,9 +4279,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")); @@ -4251,10 +4290,10 @@ 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()); @@ -4267,6 +4306,7 @@ fn test_store_get_schema_content_returns_copy() { 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"}} }); @@ -4440,7 +4480,7 @@ fn test_op13_circular_ref_does_not_hang() { let err = store .resolve_schema_refs_checked(&schema_a) .expect_err("checked resolution should reject circular refs"); - assert_eq!(err, ResolveSchemaRefsError::CircularRef); + assert!(matches!(err, StoreError::CircularRef)); } #[test] @@ -5045,3 +5085,32 @@ fn test_validate_and_resolve_accepts_well_formed_gts_ref_schema() { .validate_and_resolve_type_schema("gts.vendor.package.namespace.type.v1.0~") .expect("well-formed schema must validate and resolve"); } + +#[test] +fn test_resolve_schema_refs_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 must not be dropped. + let mut store = GtsStore::new(); + let target_schema = json!({ + "$id": "gts://gts.x.core.events.flag.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "$defs": {"closed": false} + }); + store + .register_schema("gts.x.core.events.flag.v1~", &target_schema) + .expect("register target schema"); + + let schema = json!({ + "$ref": "gts://gts.x.core.events.flag.v1~#/$defs/closed", + "description": "extra" + }); + + let resolved = store + .resolve_schema_refs_checked(&schema) + .expect("resolved non-object ref with siblings must not be reported unresolved"); + + // Per JSON Schema $ref precedence, the resolved target wins. + assert_eq!(resolved, json!(false)); +}