From c97fa061655a9b2ed8a11691e5a7f0e1645b1a63 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Tue, 29 Sep 2026 10:55:46 +0300 Subject: [PATCH] feat: support GTS spec v0.14.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the conformance suite to v0.14.4. The release adds the batch registration contract to README §9.3 and new tests for it, plus a few corner cases: - `POST /type-schemas` honors `?validate` and `gts-ref-validation` like `/entities`. A malformed mode is refused with 422 before any entry is registered. - With `validate=true` the whole batch is staged first, so an entry may `$ref` or derive from one later in the array. Staged entries are then validated until no new failure appears: a rejected entry is unstaged and the survivors are re-checked, so nothing commits on top of a rejected sibling. The batch may partly succeed; a rejected entry never commits, and an id already stored with the same content stays stored. - The trait-schema integrity check compiles each `x-gts-traits-schema` fragment under the host document's dialect instead of the default draft, so a Draft-07 tuple `items: [...]` is no longer rejected. `GtsOps::add_schemas` keeps its behavior; the new `add_schemas_with` takes the `validate` flag and the reference-validation mode. Signed-off-by: Aviator 5 --- .gts-spec-version | 2 +- README.md | 2 +- gts-cli/src/server.rs | 10 ++- gts-cli/tests/server_tests.rs | 56 ++++++++++++ gts/src/ops.rs | 157 +++++++++++++++++++++++++++++++++- gts/src/schema_traits.rs | 53 ++++++++++-- 6 files changed, 267 insertions(+), 13 deletions(-) diff --git a/.gts-spec-version b/.gts-spec-version index 818cabd..6cf9fa5 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.14.3 +v0.14.4 diff --git a/README.md b/README.md index c09214f..08d28c8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A complete Rust implementation of the Global Type System (GTS) GTS [Global Type System](https://github.com/globaltypesystem/gts-spec) is a simple, human-readable, globally unique identifier and referencing system for data type definitions (e.g., JSON Schemas) and data instances (e.g., JSON objects). This Rust implementation provides high-performance, type-safe operations for working with GTS identifiers. -Current supported GTS spec version: `0.14.3` +Current supported GTS spec version: `0.14.4` ## Roadmap diff --git a/gts-cli/src/server.rs b/gts-cli/src/server.rs index 53b042e..e790d29 100644 --- a/gts-cli/src/server.rs +++ b/gts-cli/src/server.rs @@ -287,16 +287,22 @@ async fn add_entities( } /// Per-entry outcomes are in the body, so a partly rejected batch is still a -/// 200; only a body that is not an array of schemas is refused outright. +/// 200; only a body that is not an array of schemas, or a malformed +/// `gts-ref-validation`, is refused outright. async fn add_schemas( State(state): State, + Query(params): Query, Json(body): Json>, ) -> impl IntoResponse { + let refs = match params.refs.resolve() { + Ok(refs) => refs, + Err(error) => return unprocessable(&error), + }; let mut ops = match lock_ops(&state.ops) { Ok(guard) => guard, Err(response) => return response.into_response(), }; - let result = ops.add_schemas(&body); + let result = ops.add_schemas_with(&body, params.validate, refs); Json(result).into_response() } diff --git a/gts-cli/tests/server_tests.rs b/gts-cli/tests/server_tests.rs index 455977d..f39070f 100644 --- a/gts-cli/tests/server_tests.rs +++ b/gts-cli/tests/server_tests.rs @@ -719,6 +719,54 @@ async fn test_add_schemas_requires_an_array_of_canonical_schemas() { ); } +#[tokio::test] +async fn test_add_schemas_validate_commits_only_valid_entries() { + let app = create_test_router(create_test_ops(), 0); + let schema = |name: &str, properties: serde_json::Value| { + serde_json::json!({ + "$id": format!("gts://gts.x.test6batchval._.{name}.v1~"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": properties + }) + }; + let dangling = schema( + "dangling", + serde_json::json!({"a": {"$ref": "gts://gts.x.test6batchval._.missing.v1~"}}), + ); + let batch = serde_json::json!([ + dangling, + schema( + "referrer", + serde_json::json!({"t": {"$ref": "gts://gts.x.test6batchval._.target.v1~"}}), + ), + schema("target", serde_json::json!({"n": {"type": "string"}})), + ]); + + let (status, body) = post_json(&app, "/type-schemas?validate=true", &batch).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["ok"], serde_json::json!(false), "{body}"); + assert_eq!(body["results"][0]["ok"], serde_json::json!(false)); + assert_eq!(body["results"][1]["ok"], serde_json::json!(true), "{body}"); + assert_eq!(body["results"][2]["ok"], serde_json::json!(true), "{body}"); + + let (_, body) = get_json(&app, "/entities/gts.x.test6batchval._.dangling.v1~").await; + assert_eq!( + body["ok"], + serde_json::json!(false), + "rejected entry must not commit" + ); + let (_, body) = get_json(&app, "/entities/gts.x.test6batchval._.referrer.v1~").await; + assert_eq!(body["ok"], serde_json::json!(true)); + + let (_, body) = post_json(&app, "/type-schemas", &serde_json::json!([dangling])).await; + assert_eq!( + body["ok"], + serde_json::json!(true), + "without validate a forward reference registers" + ); +} + #[tokio::test] async fn test_add_schemas_refuses_what_add_entity_refuses() { let app = create_test_router(create_test_ops(), 0); @@ -833,6 +881,14 @@ async fn test_unknown_gts_ref_validation_mode_is_rejected() { "type": "object" }), ), + ( + "/type-schemas?gts-ref-validation=unknown", + serde_json::json!([{ + "$id": format!("gts://{type_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + }]), + ), ] { let (status, body) = post_json(&app, uri, &body).await; assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{uri}"); diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 4ea87d0..332d56f 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -689,10 +689,60 @@ impl GtsOps { /// give the same verdict on the same document. Entries are independent: a /// rejected one leaves the others registered. pub fn add_schemas(&mut self, schemas: &[Value]) -> GtsAddSchemasResult { - let results: Vec = schemas - .iter() - .map(|schema| self.add_type_schema(schema)) - .collect(); + self.add_schemas_with(schemas, false, GtsRefValidation::default()) + } + + /// [`Self::add_schemas`], optionally with full validation of every entry + /// (spec v0.14.4 §9.3 Batch Type Schema Registration). + /// + /// With `validate`, the whole batch is staged first, so an entry may + /// reference or derive from one that comes later in the array. Staged + /// entries are then validated until no new failure appears: each rejected + /// entry is unstaged and the survivors are checked again, so nothing is + /// committed on top of a rejected sibling. The batch may partly succeed; + /// a rejected entry is never committed, and an id already stored with the + /// same content stays stored. `&mut self` keeps staged entries hidden + /// from concurrent readers until the call returns. + pub fn add_schemas_with( + &mut self, + schemas: &[Value], + validate: bool, + refs: GtsRefValidation, + ) -> GtsAddSchemasResult { + let mut results = Vec::with_capacity(schemas.len()); + // Staged entries awaiting validation: result index, id, whether this + // call inserted it (and so must remove it on rejection). + let mut staged = Vec::new(); + for schema in schemas { + let existed = validate + && GtsStore::declared_type_id(schema) + .is_ok_and(|type_id| self.store.get(&type_id).is_some()); + let result = self.add_type_schema(schema); + if validate && let (true, Some(type_id)) = (result.ok, &result.type_id) { + staged.push((results.len(), type_id.clone(), !existed)); + } + results.push(result); + } + + loop { + let before = staged.len(); + staged.retain(|(index, type_id, inserted)| { + let Err(e) = self.store.validate_schema_with(type_id, refs) else { + return true; + }; + if *inserted { + self.store.unregister(type_id); + } + let result: &mut GtsAddSchemaResult = &mut results[*index]; + result.ok = false; + result.error = format!("Schema validation failed: {e}"); + false + }); + if staged.len() == before { + break; + } + } + let ok = results.iter().all(|r| r.ok); GtsAddSchemasResult { ok, results } } @@ -3535,6 +3585,105 @@ mod tests { ); } + fn batch_schema(type_id: &str, properties: &Value) -> Value { + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": format!("gts://{type_id}"), + "type": "object", + "properties": properties + }) + } + + #[test] + fn test_add_schemas_with_validate_resolves_later_entries() { + let mut ops = GtsOps::new(None, None, 0); + let referrer = batch_schema( + "gts.x.batch._.referrer.v1~", + &json!({"child": {"$ref": "gts://gts.x.batch._.target.v1~"}}), + ); + let derived = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.batch._.target.v1~x.batch._.derived.v1~", + "type": "object", + "allOf": [{"$ref": "gts://gts.x.batch._.target.v1~"}] + }); + let target = batch_schema( + "gts.x.batch._.target.v1~", + &json!({"n": {"type": "string"}}), + ); + + let result = ops.add_schemas_with( + &[referrer, derived, target], + true, + GtsRefValidation::default(), + ); + assert!(result.ok, "{:?}", result.results); + assert!(ops.get_entity("gts.x.batch._.referrer.v1~").ok); + assert!( + ops.get_entity("gts.x.batch._.target.v1~x.batch._.derived.v1~") + .ok + ); + } + + #[test] + fn test_add_schemas_with_validate_commits_only_valid_entries() { + let mut ops = GtsOps::new(None, None, 0); + let valid = batch_schema("gts.x.batch._.valid.v1~", &json!({"n": {"type": "string"}})); + let invalid = batch_schema( + "gts.x.batch._.invalid.v1~", + &json!({"a": {"$ref": "gts://gts.x.batch._.missing.v1~"}}), + ); + + let result = + ops.add_schemas_with(&[valid, invalid.clone()], true, GtsRefValidation::default()); + assert!(!result.ok); + assert!(result.results[0].ok, "{}", result.results[0].error); + assert!(!result.results[1].ok); + assert!(ops.get_entity("gts.x.batch._.valid.v1~").ok); + assert!(!ops.get_entity("gts.x.batch._.invalid.v1~").ok); + + // Without `validate` the same forward reference registers. + assert!(ops.add_schemas(&[invalid]).ok); + } + + #[test] + fn test_add_schemas_with_validate_rejects_dependents_of_rejected_entries() { + let mut ops = GtsOps::new(None, None, 0); + // `b` comes first, so it passes while `a` is still staged and must be + // re-checked once `a` is rejected. + let b = batch_schema( + "gts.x.batch._.b.v1~", + &json!({"x": {"type": "string", "x-gts-ref": "gts.x.batch._.a.v1~"}}), + ); + let a = batch_schema( + "gts.x.batch._.a.v1~", + &json!({"r": {"type": "string", "x-gts-ref": "gts.x.batch._.missing.v1~"}}), + ); + + let result = ops.add_schemas_with(&[b, a], true, GtsRefValidation::AnyPresent); + assert!(!result.results[0].ok, "b depends on the rejected a"); + assert!(!result.results[1].ok); + assert!(!ops.get_entity("gts.x.batch._.a.v1~").ok); + assert!(!ops.get_entity("gts.x.batch._.b.v1~").ok); + } + + #[test] + fn test_add_schemas_with_validate_keeps_previously_stored_entries() { + let mut ops = GtsOps::new(None, None, 0); + let stored = batch_schema( + "gts.x.batch._.stored.v1~", + &json!({"a": {"$ref": "gts://gts.x.batch._.missing.v1~"}}), + ); + assert!(ops.add_schemas(std::slice::from_ref(&stored)).ok); + + let result = ops.add_schemas_with(&[stored], true, GtsRefValidation::default()); + assert!(!result.ok, "the stored entry is still invalid"); + assert!( + ops.get_entity("gts.x.batch._.stored.v1~").ok, + "a rejected resubmission must not remove what was already stored" + ); + } + #[test] fn test_add_entity_rolls_back_an_instance_that_fails_validation() { let mut ops = GtsOps::new(None, None, 0); diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index aea1307..dcd43fc 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -112,7 +112,8 @@ impl EffectiveTraits { check_unresolved: bool, entity_exists: Option, ) -> Result<(), Vec> { - validate_trait_schema_integrity(&self.resolved_trait_schemas)?; + let dialect = self.schema.get("$schema").and_then(Value::as_str); + validate_trait_schema_integrity(&self.resolved_trait_schemas, dialect)?; validate_trait_schema_compatibility(&self.resolved_trait_schemas)?; if !self.has_schema() { @@ -269,10 +270,17 @@ pub fn validate_traits_chain(chain_schemas: &[(String, Value)]) -> Result<(), Ve /// preserving indexed error messages for malformed inputs. Run first by /// [`EffectiveTraits::validate`]. /// +/// A fragment without its own `$schema` is compiled under `dialect`, the host +/// document's, so a Draft-07 tuple `items: [...]` is not judged by the 2020-12 +/// metaschema. `None` leaves the validator's default draft. +/// /// # Errors /// 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> { +fn validate_trait_schema_integrity( + resolved_trait_schemas: &[Value], + dialect: Option<&str>, +) -> 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 @@ -291,8 +299,18 @@ fn validate_trait_schema_integrity(resolved_trait_schemas: &[Value]) -> Result<( for (i, ts) in resolved_trait_schemas.iter().enumerate() { match ts { Value::Bool(_) => {} - Value::Object(_) => { - if let Err(e) = crate::json_schema::validator_for(ts) { + Value::Object(fragment) => { + let pinned; + let schema = match dialect { + Some(dialect) if !fragment.contains_key("$schema") => { + let mut fragment = fragment.clone(); + fragment.insert("$schema".to_owned(), Value::String(dialect.to_owned())); + pinned = Value::Object(fragment); + &pinned + } + _ => ts, + }; + if let Err(e) = crate::json_schema::validator_for(schema) { return Err(vec![format!( "x-gts-traits-schema[{i}] is not a valid JSON Schema: {e}" )]); @@ -1036,7 +1054,7 @@ mod tests { // 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(); + let err = validate_trait_schema_integrity(&schemas, None).unwrap_err(); assert!( err.iter() .any(|m| m.contains("must be an object subschema or a boolean")), @@ -1059,6 +1077,31 @@ mod tests { assert!(inlined.get("$ref").is_none()); } + #[test] + fn test_trait_schema_integrity_uses_host_dialect() { + // Tuple-form `items` is legal Draft-07 but not 2020-12, so the fragment + // must be judged by the host document's metaschema. + let host = |dialect: &str| { + vec![( + "base~".to_owned(), + json!({"$schema": dialect, + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"pair": {"type": "array", "items": [{"type": "string"}]}} + } + }), + )] + }; + assert!(validate_traits_chain(&host("http://json-schema.org/draft-07/schema#")).is_ok()); + let err = validate_traits_chain(&host("https://json-schema.org/draft/2020-12/schema")) + .unwrap_err(); + assert!( + err.iter().any(|m| m.contains("is not a valid JSON Schema")), + "2020-12 must reject tuple items: {err:?}" + ); + } + #[test] fn test_no_traits_schema_passes() { let chain = vec![(