diff --git a/.gts-spec-version b/.gts-spec-version index 48bad2a..818cabd 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.13.4 +v0.14.3 diff --git a/Cargo.lock b/Cargo.lock index ef14f85..9198889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,17 +336,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "dyn-clone" version = "1.0.20" @@ -398,9 +387,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" dependencies = [ "bit-set", "regex-automata", @@ -453,12 +442,12 @@ dependencies = [ [[package]] name = "fraction" -version = "0.15.4" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" dependencies = [ - "lazy_static", "num", + "num-bigint", ] [[package]] @@ -554,8 +543,8 @@ version = "0.13.0" dependencies = [ "gts-id", "jsonschema", + "jsonschema-regex", "num-cmp", - "regress", "schemars", "serde", "serde-saphyr", @@ -657,21 +646,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "heck" version = "0.5.0" @@ -782,115 +765,12 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -929,9 +809,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.40.2" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba783d17473c27cfd4d1d72785dc1c26d5faba8072f50fec4ebea179bec8f33d" +checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa" dependencies = [ "ahash", "bytecount", @@ -940,20 +820,46 @@ dependencies = [ "fancy-regex", "fraction", "getrandom 0.3.4", - "idna", "itoa", + "jsonschema-regex", + "jsonschema-value", "num-cmp", "num-traits", "percent-encoding", "referencing", "regex", - "regex-syntax", "serde", "serde_json", + "strum", "unicode-general-category", "uuid-simd", ] +[[package]] +name = "jsonschema-regex" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -987,12 +893,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "lock_api" version = "0.4.14" @@ -1029,6 +929,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1077,9 +983,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1199,15 +1105,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1290,14 +1187,16 @@ dependencies = [ [[package]] name = "referencing" -version = "0.40.2" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef39a30a317e883d1ef4c43aa849f90f480d90bb24904fd38266e61d6be58f2" +checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e" dependencies = [ "ahash", "fluent-uri", "getrandom 0.3.4", - "hashbrown 0.16.1", + "hashbrown 0.17.1", + "itoa", + "micromap", "parking_lot", "percent-encoding", "serde_json", @@ -1317,9 +1216,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1332,15 +1231,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "regress" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" -dependencies = [ - "memchr", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1578,18 +1468,33 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.117" @@ -1607,17 +1512,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "target-triple" version = "1.0.0" @@ -1675,16 +1569,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tokio" version = "1.52.3" @@ -1881,12 +1765,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -2224,35 +2102,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.50" @@ -2273,62 +2122,8 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 0602e71..4beb01b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -167,7 +167,6 @@ thiserror = "2.0" anyhow = "1.0" regex = "1.12" # ECMAScript-grammar regex parsing for the `format: regex` assertion. -regress = "0.12" uuid = { version = "1.19", features = ["serde", "v4", "v5"] } # CLI dependencies @@ -184,7 +183,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = "0.4" # JSON Schema validation -jsonschema = { version = "0.40", default-features = false } +jsonschema = { version = "0.57", default-features = false } +jsonschema-regex = "0.57" # Exact comparison between differently typed numbers, as used by `jsonschema`. num-cmp = "0.1" diff --git a/README.md b/README.md index 1bd5a9a..c09214f 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.13.4` +Current supported GTS spec version: `0.14.3` ## Roadmap diff --git a/gts-cli/src/server.rs b/gts-cli/src/server.rs index 111fd80..53b042e 100644 --- a/gts-cli/src/server.rs +++ b/gts-cli/src/server.rs @@ -7,6 +7,7 @@ use axum::{ routing::{get, post}, }; use gts::GtsOps; +use gts::GtsRefValidation; use gts::ops::AddEntityRejection; use serde::Deserialize; use serde_json::{Value, json}; @@ -66,7 +67,7 @@ impl GtsHttpServer { .route("/entities", get(get_entities).post(add_entity)) .route("/entities/{gts_id}", get(get_entity)) .route("/entities/bulk", post(add_entities)) - .route("/type-schemas", post(add_schema)) + .route("/type-schemas", post(add_schemas)) .route("/validate-id", get(validate_id)) .route("/extract-id", post(extract_id)) .route("/parse-id", get(parse_id)) @@ -160,16 +161,36 @@ struct LimitQuery { struct AddEntityQuery { #[serde(default)] validate: bool, + #[serde(flatten)] + refs: GtsRefValidationQuery, } -fn default_limit() -> usize { - 100 +/// The `gts-ref-validation` request parameter (spec v0.14 §9.6). +#[derive(Deserialize, Default)] +struct GtsRefValidationQuery { + #[serde(rename = "gts-ref-validation")] + mode: Option, } -#[derive(Deserialize)] -struct SchemaRegister { - type_id: String, - type_schema: Value, +impl GtsRefValidationQuery { + /// The requested mode, or why the spelling was rejected. + fn resolve(&self) -> Result { + self.mode + .as_deref() + .map_or_else(|| Ok(GtsRefValidation::default()), GtsRefValidation::parse) + } +} + +fn unprocessable(error: &str) -> axum::response::Response { + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"ok": false, "error": error})), + ) + .into_response() +} + +fn default_limit() -> usize { + 100 } #[derive(Deserialize, serde::Serialize)] @@ -236,11 +257,15 @@ async fn add_entity( 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_entity(&body, params.validate); + let result = ops.add_entity_with(&body, params.validate, refs); let status = match (result.ok, result.rejection) { (true, _) => StatusCode::OK, (false, Some(AddEntityRejection::Conflict)) => StatusCode::CONFLICT, @@ -261,21 +286,18 @@ async fn add_entities( Json(result).into_response() } -async fn add_schema( +/// 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. +async fn add_schemas( State(state): State, - Json(body): Json, + Json(body): Json>, ) -> impl IntoResponse { let mut ops = match lock_ops(&state.ops) { Ok(guard) => guard, Err(response) => return response.into_response(), }; - let result = ops.add_schema(body.type_id, &body.type_schema); - let status = match (result.ok, result.rejection) { - (true, _) => StatusCode::OK, - (false, Some(AddEntityRejection::Conflict)) => StatusCode::CONFLICT, - (false, None) => StatusCode::UNPROCESSABLE_ENTITY, - }; - (status, Json(result)).into_response() + let result = ops.add_schemas(&body); + Json(result).into_response() } async fn validate_id( @@ -337,37 +359,52 @@ async fn id_to_uuid( async fn validate_instance( State(state): State, + Query(params): Query, Json(body): Json, ) -> impl IntoResponse { + let refs = match params.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.validate_instance(&body.instance_id); + let result = ops.validate_instance_with(&body.instance_id, refs); Json(result).into_response() } async fn validate_schema( State(state): State, + Query(params): Query, Json(body): Json, ) -> impl IntoResponse { + let refs = match params.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.validate_schema(&body.type_id); + let result = ops.validate_schema_with(&body.type_id, refs); Json(result).into_response() } async fn validate_entity( State(state): State, + Query(params): Query, Json(body): Json, ) -> impl IntoResponse { + let refs = match params.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.validate_entity(&body.entity_id); + let result = ops.validate_entity_with(&body.entity_id, refs); Json(result).into_response() } diff --git a/gts-cli/tests/server_tests.rs b/gts-cli/tests/server_tests.rs index 7edc1fb..455977d 100644 --- a/gts-cli/tests/server_tests.rs +++ b/gts-cli/tests/server_tests.rs @@ -179,46 +179,33 @@ async fn test_uuid_endpoint() { } #[tokio::test] -async fn test_add_schema_endpoint() { - let ops = create_test_ops(); - let app = create_test_router(ops, 0); +async fn test_add_schemas_endpoint() { + let app = create_test_router(create_test_ops(), 0); + let type_id = "gts.x.test6.schemaendpoint.type.v1~"; - // A registrable id: `test:schema:v1` is not a GTS identifier, so this - // route could only ever answer it with a refusal. - let schema = serde_json::json!({ - "type_id": "gts.x.test6.schemaendpoint.type.v1~", - "type_schema": { - "$id": "gts://gts.x.test6.schemaendpoint.type.v1~", + let (status, body) = post_json( + &app, + "/type-schemas", + &serde_json::json!([{ + "$id": format!("gts://{type_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "properties": { - "name": { "type": "string" } - } - } - }); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/type-schemas") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&schema).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); + "properties": {"name": {"type": "string"}} + }]), + ) + .await; - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(status, StatusCode::OK); assert_eq!( body["ok"], serde_json::json!(true), - "a 200 here must mean the schema actually registered" + "a 200 with ok=true must mean the schema actually registered: {body}" ); + assert_eq!(body["results"][0]["ok"], serde_json::json!(true)); + assert_eq!(body["results"][0]["type_id"], serde_json::json!(type_id)); + + let (status, _) = get_json(&app, &format!("/entities/{type_id}")).await; + assert_eq!(status, StatusCode::OK); } #[tokio::test] @@ -626,17 +613,16 @@ async fn test_add_entity_type_schema_resubmission() { } #[tokio::test] -async fn test_add_schema_resubmission() { +async fn test_add_schemas_resubmission() { let app = create_test_router(create_test_ops(), 0); let type_id = "gts.x.test6.schemapost.type.v1~"; let request = |value_type: &str| { - serde_json::json!({ - "type_id": type_id, - "type_schema": { - "type": "object", - "properties": {"value": {"type": value_type}} - } - }) + serde_json::json!([{ + "$id": format!("gts://{type_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"value": {"type": value_type}} + }]) }; let (status, body) = post_json(&app, "/type-schemas", &request("string")).await; @@ -645,14 +631,30 @@ async fn test_add_schema_resubmission() { let (status, body) = post_json(&app, "/type-schemas", &request("string")).await; assert_eq!(status, StatusCode::OK); - assert_eq!(body["ok"], serde_json::json!(true)); - assert_eq!(body["id"], serde_json::json!(type_id)); + assert_eq!( + body["ok"], + serde_json::json!(true), + "identical content is accepted" + ); let (status, body) = post_json(&app, "/type-schemas", &request("integer")).await; - assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + status, + StatusCode::OK, + "entry outcomes are reported in the body" + ); assert_eq!(body["ok"], serde_json::json!(false)); - assert!(body.get("rejection").is_none()); - assert!(body.get("id").is_none()); + let refused = &body["results"][0]; + assert_eq!(refused["ok"], serde_json::json!(false)); + assert_eq!(refused["type_id"], serde_json::json!(type_id)); + assert!( + refused["error"] + .as_str() + .unwrap_or_default() + .contains("already registered with different content"), + "{refused}" + ); + assert!(refused.get("rejection").is_none()); let (status, body) = get_json(&app, &format!("/entities/{type_id}")).await; assert_eq!(status, StatusCode::OK); @@ -664,44 +666,75 @@ async fn test_add_schema_resubmission() { } #[tokio::test] -async fn test_add_schema_reports_a_failed_registration_as_unprocessable() { +async fn test_add_schemas_requires_an_array_of_canonical_schemas() { let app = create_test_router(create_test_ops(), 0); + let schema = serde_json::json!({ + "$id": "gts://gts.x.test6.schemabody.type.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + }); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/type-schemas") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&schema).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "a single object is not a batch" + ); let (status, body) = post_json( &app, "/type-schemas", - &serde_json::json!({ - "type_id": "not-a-gts-id", - "type_schema": {"type": "object"}, - }), + &serde_json::json!([ + schema, + {"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}, + ]), ) .await; - + assert_eq!(status, StatusCode::OK); assert_eq!( - status, - StatusCode::UNPROCESSABLE_ENTITY, - "a client reading the status must not see a failed registration as success" + body["ok"], + serde_json::json!(false), + "a batch with a rejected entry is not ok" + ); + assert_eq!(body["results"][0]["ok"], serde_json::json!(true)); + assert_eq!(body["results"][1]["ok"], serde_json::json!(false)); + assert!(body["results"][1].get("type_id").is_none()); + assert!( + body["results"][1]["error"] + .as_str() + .unwrap_or_default() + .contains("'$id'"), + "{body}" ); - assert_eq!(body["ok"], serde_json::json!(false)); } #[tokio::test] -async fn test_add_schema_refuses_what_add_entity_refuses() { +async fn test_add_schemas_refuses_what_add_entity_refuses() { let app = create_test_router(create_test_ops(), 0); - let misplaced = serde_json::json!({ - "type": "object", - "properties": {"a": {"type": "string", "x-gts-traits": {"k": "v"}}}, - }); + let misplaced = |type_id: &str| { + serde_json::json!({ + "$id": format!("gts://{type_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"a": {"type": "string", "x-gts-traits": {"k": "v"}}}, + }) + }; let (entity_status, entity_body) = post_json( &app, "/entities", - &serde_json::json!({ - "$id": "gts://gts.x.test6.parity.viaentity.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": {"a": {"type": "string", "x-gts-traits": {"k": "v"}}}, - }), + &misplaced("gts.x.test6.parity.viaentity.v1~"), ) .await; assert_eq!(entity_status, StatusCode::UNPROCESSABLE_ENTITY); @@ -710,14 +743,16 @@ async fn test_add_schema_refuses_what_add_entity_refuses() { let (schema_status, schema_body) = post_json( &app, "/type-schemas", - &serde_json::json!({"type_id": type_id, "type_schema": misplaced}), + &serde_json::json!([misplaced(type_id)]), ) .await; + assert_eq!(schema_status, StatusCode::OK); assert_eq!( - schema_status, entity_status, + schema_body["results"][0]["ok"], + serde_json::json!(false), "both ingest routes must give the same verdict on the same content" ); - assert_eq!(schema_body["error"], entity_body["error"]); + assert_eq!(schema_body["results"][0]["error"], entity_body["error"]); let (status, body) = get_json(&app, &format!("/entities/{type_id}")).await; assert_eq!(status, StatusCode::OK); @@ -769,3 +804,75 @@ async fn test_add_entity_accepts_corrected_body_after_failed_validation() { assert_eq!(body["ok"], serde_json::json!(true)); assert_eq!(body["id"], serde_json::json!(instance_id)); } + +#[tokio::test] +async fn test_unknown_gts_ref_validation_mode_is_rejected() { + let app = create_test_router(create_test_ops(), 0); + let type_id = "gts.x.modes.srv.target.v1~"; + + // Each body is the one its endpoint accepts, so a 422 can only come from + // the mode and not from deserialization. + for (uri, body) in [ + ( + "/validate-type-schema?gts-ref-validation=unknown", + serde_json::json!({"type_id": type_id}), + ), + ( + "/validate-instance?gts-ref-validation=unknown", + serde_json::json!({"instance_id": format!("{type_id}x.v._.a.v1")}), + ), + ( + "/validate-entity?gts-ref-validation=unknown", + serde_json::json!({"entity_id": type_id}), + ), + ( + "/entities?validate=true>s-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}"); + assert!( + body["error"] + .as_str() + .unwrap_or_default() + .contains("gts-ref-validation"), + "{uri}: {body}" + ); + } +} + +#[tokio::test] +async fn test_gts_ref_validation_mode_gates_registration() { + let app = create_test_router(create_test_ops(), 0); + let holder = |name: &str| { + serde_json::json!({ + "$id": format!("gts://gts.x.modes.srv.{name}.v1~"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "ref": {"type": "string", "x-gts-ref": "gts.x.modes.srv.missing.v1~"} + } + }) + }; + + let (status, _) = post_json( + &app, + "/entities?validate=true>s-ref-validation=none", + &holder("lenient"), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, _) = post_json( + &app, + "/entities?validate=true>s-ref-validation=any-present", + &holder("strict"), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} diff --git a/gts-dylint/Cargo.lock b/gts-dylint/Cargo.lock index ae14dd5..0b408c9 100644 --- a/gts-dylint/Cargo.lock +++ b/gts-dylint/Cargo.lock @@ -482,9 +482,9 @@ dependencies = [ [[package]] name = "fancy-regex" -version = "0.17.0" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" dependencies = [ "bit-set", "regex-automata", @@ -541,12 +541,12 @@ dependencies = [ [[package]] name = "fraction" -version = "0.15.4" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" dependencies = [ - "lazy_static", "num", + "num-bigint", ] [[package]] @@ -633,12 +633,23 @@ dependencies = [ "url", ] +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "gts" version = "0.13.0" dependencies = [ "gts-id", "jsonschema", + "jsonschema-regex", "num-cmp", "schemars", "serde", @@ -647,6 +658,7 @@ dependencies = [ "shellexpand", "thiserror 2.0.18", "tracing", + "uuid", "walkdir", ] @@ -685,9 +697,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -695,10 +707,10 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.17.1" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" @@ -825,7 +837,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown", ] [[package]] @@ -888,9 +900,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.40.2" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba783d17473c27cfd4d1d72785dc1c26d5faba8072f50fec4ebea179bec8f33d" +checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa" dependencies = [ "ahash", "bytecount", @@ -899,20 +911,46 @@ dependencies = [ "fancy-regex", "fraction", "getrandom 0.3.4", - "idna", "itoa", + "jsonschema-regex", + "jsonschema-value", "num-cmp", "num-traits", "percent-encoding", "referencing", "regex", - "regex-syntax", "serde", "serde_json", + "strum", "unicode-general-category", "uuid-simd", ] +[[package]] +name = "jsonschema-regex" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1007,6 +1045,12 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "miow" version = "0.6.1" @@ -1038,9 +1082,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1329,14 +1373,16 @@ dependencies = [ [[package]] name = "referencing" -version = "0.40.2" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef39a30a317e883d1ef4c43aa849f90f480d90bb24904fd38266e61d6be58f2" +checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e" dependencies = [ "ahash", "fluent-uri", "getrandom 0.3.4", - "hashbrown 0.16.1", + "hashbrown", + "itoa", + "micromap", "parking_lot", "percent-encoding", "serde_json", @@ -1356,9 +1402,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1411,17 +1457,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "saphyr-parser-bw" -version = "0.0.611" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dec0c833db75dc98957956b303fe447ffc5eb13f2325ef4c2350f7f3aa69e3" -dependencies = [ - "arraydeque", - "smallvec", - "thiserror 2.0.18", -] - [[package]] name = "schemars" version = "1.2.1" @@ -1476,19 +1511,17 @@ dependencies = [ [[package]] name = "serde-saphyr" -version = "0.0.24" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83ad47c2f14654528a89495f8d0dbc64173176f8512c7c72386cbe81009f661" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" dependencies = [ - "ahash", "annotate-snippets", "base64", "encoding_rs_io", - "getrandom 0.3.4", + "granit-parser", "nohash-hasher", "num-traits", - "saphyr-parser-bw", - "serde", + "serde_core", "smallvec", "zmij", ] @@ -1585,6 +1618,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.118" @@ -2174,6 +2228,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/gts-macros/tests/inheritance_tests.rs b/gts-macros/tests/inheritance_tests.rs index c744b09..ded05b3 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -376,10 +376,10 @@ mod tests { /// Helper to register 3-level event schemas (`BaseEventV1` -> `AuditPayloadV1` -> `PlaceOrderDataV1`) fn register_three_level_event_schemas(ops: &mut gts::GtsOps) { let base_schema = BaseEventV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - BaseEventV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "BaseEventV1 schema registration failed: {}", @@ -387,10 +387,10 @@ mod tests { ); let audit_schema = AuditPayloadV1::<()>::gts_schema_with_refs(); - let audit_result = ops.add_schema( - AuditPayloadV1::<()>::gts_type_id().clone().into_string(), - &audit_schema, - ); + let audit_result = ops + .add_schemas(std::slice::from_ref(&audit_schema)) + .results + .remove(0); assert!( audit_result.ok, "AuditPayloadV1 schema registration failed: {}", @@ -398,10 +398,10 @@ mod tests { ); let order_schema = PlaceOrderDataV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - PlaceOrderDataV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "PlaceOrderDataV1 schema registration failed: {}", @@ -412,10 +412,10 @@ mod tests { /// Helper to register 2-level event schemas (`BaseEventV1` -> `SimplePayloadV1`) fn register_two_level_event_schemas(ops: &mut gts::GtsOps) { let base_schema = BaseEventV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - BaseEventV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "BaseEventV1 schema registration failed: {}", @@ -423,10 +423,10 @@ mod tests { ); let simple_schema = SimplePayloadV1::gts_schema_with_refs(); - let simple_result = ops.add_schema( - SimplePayloadV1::gts_type_id().clone().into_string(), - &simple_schema, - ); + let simple_result = ops + .add_schemas(std::slice::from_ref(&simple_schema)) + .results + .remove(0); assert!( simple_result.ok, "SimplePayloadV1 schema registration failed: {}", @@ -1168,10 +1168,10 @@ mod tests { let mut ops = gts::GtsOps::new(None, None, 0); let base_schema = TopicV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - TopicV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "Base schema registration should succeed: {}", @@ -1180,10 +1180,10 @@ mod tests { // Register the OrderTopicConfigV1 schema (empty struct) using GtsOps let empty_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let empty_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &empty_schema, - ); + let empty_result = ops + .add_schemas(std::slice::from_ref(&empty_schema)) + .results + .remove(0); assert!( empty_result.ok, "Empty schema registration should succeed: {}", @@ -1264,20 +1264,20 @@ mod tests { let base_schema = TopicV1::<()>::gts_schema_with_refs(); let empty_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let base_result = ops.add_schema( - TopicV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "Base schema registration should succeed: {}", base_result.error ); - let empty_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &empty_schema, - ); + let empty_result = ops + .add_schemas(std::slice::from_ref(&empty_schema)) + .results + .remove(0); assert!( empty_result.ok, "Empty schema registration should succeed: {}", @@ -1448,20 +1448,20 @@ mod tests { // Register schemas for validation let mut ops = gts::GtsOps::new(None, None, 0); let schema = TopicV1WithIdV1::<()>::gts_schema_with_refs(); - let result = ops.add_schema( - TopicV1WithIdV1::<()>::gts_type_id().clone().into_string(), - &schema, - ); + let result = ops + .add_schemas(std::slice::from_ref(&schema)) + .results + .remove(0); assert!( result.ok, "TopicV1WithIdV1 schema registration failed: {}", result.error ); let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -1522,22 +1522,20 @@ mod tests { // Register schemas for validation let mut ops = gts::GtsOps::new(None, None, 0); let schema = TopicV1WithGtsIdV1::<()>::gts_schema_with_refs(); - let result = ops.add_schema( - TopicV1WithGtsIdV1::<()>::gts_type_id() - .clone() - .into_string(), - &schema, - ); + let result = ops + .add_schemas(std::slice::from_ref(&schema)) + .results + .remove(0); assert!( result.ok, "TopicV1WithGtsIdV1 schema registration failed: {}", result.error ); let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -1590,22 +1588,20 @@ mod tests { // Register schemas for validation let mut ops = gts::GtsOps::new(None, None, 0); let schema = TopicV1WithGtsIdCamelV1::<()>::gts_schema_with_refs(); - let result = ops.add_schema( - TopicV1WithGtsIdCamelV1::<()>::gts_type_id() - .clone() - .into_string(), - &schema, - ); + let result = ops + .add_schemas(std::slice::from_ref(&schema)) + .results + .remove(0); assert!( result.ok, "TopicV1WithGtsIdCamelV1 schema registration failed: {}", result.error ); let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -1658,12 +1654,10 @@ mod tests { // Register schemas for validation let mut ops = gts::GtsOps::new(None, None, 0); let schema = TopicV1WithGtsTypeV1::<()>::gts_schema_with_refs(); - let result = ops.add_schema( - TopicV1WithGtsTypeV1::<()>::gts_type_id() - .clone() - .into_string(), - &schema, - ); + let result = ops + .add_schemas(std::slice::from_ref(&schema)) + .results + .remove(0); assert!( result.ok, "TopicV1WithGtsTypeV1 schema registration failed: {}", @@ -1710,12 +1704,10 @@ mod tests { // Register schemas for validation let mut ops = gts::GtsOps::new(None, None, 0); let schema = TopicV1WithGtsTypeCamelV1::<()>::gts_schema_with_refs(); - let result = ops.add_schema( - TopicV1WithGtsTypeCamelV1::<()>::gts_type_id() - .clone() - .into_string(), - &schema, - ); + let result = ops + .add_schemas(std::slice::from_ref(&schema)) + .results + .remove(0); assert!( result.ok, "TopicV1WithGtsTypeCamelV1 schema registration failed: {}", @@ -1984,20 +1976,20 @@ mod tests { register_two_level_event_schemas(&mut ops); // Also register 3-level schemas (AuditPayloadV1 and PlaceOrderDataV1) let audit_schema = AuditPayloadV1::<()>::gts_schema_with_refs(); - let audit_result = ops.add_schema( - AuditPayloadV1::<()>::gts_type_id().clone().into_string(), - &audit_schema, - ); + let audit_result = ops + .add_schemas(std::slice::from_ref(&audit_schema)) + .results + .remove(0); assert!( audit_result.ok, "AuditPayloadV1 schema registration failed: {}", audit_result.error ); let order_schema = PlaceOrderDataV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - PlaceOrderDataV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "PlaceOrderDataV1 schema registration failed: {}", @@ -2127,10 +2119,10 @@ mod tests { // Register base schema let base_schema = BaseEventV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - BaseEventV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "Base schema registration should succeed: {}", @@ -2139,10 +2131,10 @@ mod tests { // Register SimplePayloadV1 schema let simple_schema = SimplePayloadV1::gts_schema_with_refs(); - let simple_result = ops.add_schema( - SimplePayloadV1::gts_type_id().clone().into_string(), - &simple_schema, - ); + let simple_result = ops + .add_schemas(std::slice::from_ref(&simple_schema)) + .results + .remove(0); assert!( simple_result.ok, "SimplePayloadV1 schema registration should succeed: {}", @@ -2175,10 +2167,10 @@ mod tests { /// Helper to register all schemas needed for `TopicV1` hierarchy fn register_topic_schemas(ops: &mut gts::GtsOps) { let base_schema = TopicV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - TopicV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "TopicV1 schema registration failed: {}", @@ -2186,10 +2178,10 @@ mod tests { ); let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -2286,10 +2278,10 @@ mod tests { // Register TopicV1WithIdV1 schema let schema = TopicV1WithIdV1::<()>::gts_schema_with_refs(); - let result = ops.add_schema( - TopicV1WithIdV1::<()>::gts_type_id().clone().into_string(), - &schema, - ); + let result = ops + .add_schemas(std::slice::from_ref(&schema)) + .results + .remove(0); assert!( result.ok, "TopicV1WithIdV1 schema registration failed: {}", @@ -2298,10 +2290,10 @@ mod tests { // Register OrderTopicConfigV1 schema (needed for nested type) let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -2338,12 +2330,10 @@ mod tests { // Register schemas let schema1 = TopicV1WithGtsIdV1::<()>::gts_schema_with_refs(); - let result1 = ops.add_schema( - TopicV1WithGtsIdV1::<()>::gts_type_id() - .clone() - .into_string(), - &schema1, - ); + let result1 = ops + .add_schemas(std::slice::from_ref(&schema1)) + .results + .remove(0); assert!( result1.ok, "TopicV1WithGtsIdV1 schema registration failed: {}", @@ -2351,10 +2341,10 @@ mod tests { ); let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -2413,12 +2403,10 @@ mod tests { // Register schemas let schema1 = TopicV1WithGtsTypeV1::<()>::gts_schema_with_refs(); - let result1 = ops.add_schema( - TopicV1WithGtsTypeV1::<()>::gts_type_id() - .clone() - .into_string(), - &schema1, - ); + let result1 = ops + .add_schemas(std::slice::from_ref(&schema1)) + .results + .remove(0); assert!( result1.ok, "TopicV1WithGtsTypeV1 schema registration failed: {}", @@ -2426,10 +2414,10 @@ mod tests { ); let order_schema = OrderTopicConfigV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - OrderTopicConfigV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "OrderTopicConfigV1 schema registration failed: {}", @@ -2494,10 +2482,10 @@ mod tests { // Register schemas let base_schema = BaseEventV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - BaseEventV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "BaseEventV1 schema registration failed: {}", @@ -2505,10 +2493,10 @@ mod tests { ); let simple_schema = SimplePayloadV1::gts_schema_with_refs(); - let simple_result = ops.add_schema( - SimplePayloadV1::gts_type_id().clone().into_string(), - &simple_schema, - ); + let simple_result = ops + .add_schemas(std::slice::from_ref(&simple_schema)) + .results + .remove(0); assert!( simple_result.ok, "SimplePayloadV1 schema registration failed: {}", @@ -2556,10 +2544,10 @@ mod tests { // Register all schemas in the inheritance chain let base_schema = BaseEventV1::<()>::gts_schema_with_refs(); - let base_result = ops.add_schema( - BaseEventV1::<()>::gts_type_id().clone().into_string(), - &base_schema, - ); + let base_result = ops + .add_schemas(std::slice::from_ref(&base_schema)) + .results + .remove(0); assert!( base_result.ok, "BaseEventV1 schema registration failed: {}", @@ -2567,10 +2555,10 @@ mod tests { ); let audit_schema = AuditPayloadV1::<()>::gts_schema_with_refs(); - let audit_result = ops.add_schema( - AuditPayloadV1::<()>::gts_type_id().clone().into_string(), - &audit_schema, - ); + let audit_result = ops + .add_schemas(std::slice::from_ref(&audit_schema)) + .results + .remove(0); assert!( audit_result.ok, "AuditPayloadV1 schema registration failed: {}", @@ -2578,10 +2566,10 @@ mod tests { ); let order_schema = PlaceOrderDataV1::gts_schema_with_refs(); - let order_result = ops.add_schema( - PlaceOrderDataV1::gts_type_id().clone().into_string(), - &order_schema, - ); + let order_result = ops + .add_schemas(std::slice::from_ref(&order_schema)) + .results + .remove(0); assert!( order_result.ok, "PlaceOrderDataV1 schema registration failed: {}", diff --git a/gts/Cargo.toml b/gts/Cargo.toml index 33643a1..ab59fff 100644 --- a/gts/Cargo.toml +++ b/gts/Cargo.toml @@ -21,7 +21,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true jsonschema.workspace = true -regress.workspace = true +jsonschema-regex.workspace = true uuid.workspace = true num-cmp.workspace = true schemars.workspace = true diff --git a/gts/src/json_schema.rs b/gts/src/json_schema.rs index 38141a3..79a09da 100644 --- a/gts/src/json_schema.rs +++ b/gts/src/json_schema.rs @@ -3,12 +3,11 @@ //! GTS asserts `uuid` on every dialect and uses ECMA 262 syntax for `regex`. use serde_json::{Map, Value}; -use std::sync::{OnceLock, mpsc}; use uuid::Uuid; /// Configures GTS formats without asserting optional formats on newer drafts. #[must_use] -fn options_for(schema: &Value) -> jsonschema::ValidationOptions { +fn options_for(schema: &Value) -> jsonschema::ValidationOptions<'_> { let mut options = jsonschema::options() .with_format("uuid", is_valid_uuid) .with_format("regex", is_valid_ecma262_regex); @@ -88,7 +87,8 @@ pub fn validator_for( /// Compiles `schema` with GTS formats and `x-gts-ref` enforcement. /// -/// `exists` enables store-aware reference checks; `None` checks patterns only. +/// `/$id` names the document's own top-level `$id`. `exists` enables +/// store-aware reference checks; `None` checks patterns only. /// /// # Errors /// @@ -98,7 +98,45 @@ pub fn gts_validator_for( schema: &Value, exists: Option, ) -> Result> { - crate::x_gts_ref::with_x_gts_ref(options_for(schema), schema, exists).build(schema) + let selected = crate::x_gts_ref::XGtsRefValidator::self_id(schema); + gts_validator_for_type(schema, selected.as_deref(), &[], exists) +} + +/// [`gts_validator_for`] on behalf of `selected_type`, the GTS type being +/// validated, which `/$id` names wherever it is declared (spec v0.14 §9.6). +/// +/// `resources` are the other documents `schema` references, by URI; the +/// validator follows every `$ref` itself, under the rules of its dialect. +/// +/// # Errors +/// +/// See [`gts_validator_for`]. +pub fn gts_validator_for_type( + schema: &Value, + selected_type: Option<&str>, + resources: &[(String, &Value)], + exists: Option, +) -> Result> { + let builder = jsonschema::Registry::new() + .extend(resources.iter().map(|(uri, document)| (uri, *document))) + .map_err(jsonschema::ValidationError::from)?; + let builder = if let Some(uri) = schema.get("$id").and_then(Value::as_str) { + builder + .add(uri, schema) + .map_err(jsonschema::ValidationError::from)? + } else { + builder + }; + let registry = builder + .prepare() + .map_err(jsonschema::ValidationError::from)?; + crate::x_gts_ref::with_x_gts_ref( + options_for(schema), + selected_type.map(str::to_owned), + exists, + ) + .with_registry(®istry) + .build(schema) } /// A validation result split by diagnostic source. @@ -117,11 +155,25 @@ pub struct Diagnosis { /// Validates exactly, omitting details when explaining recursive combinators /// would grow exponentially. pub fn diagnose(validator: &jsonschema::Validator, schema: &Value, instance: &Value) -> Diagnosis { + diagnose_resolved(validator, schema, Some(schema), instance) +} + +/// [`diagnose`] for a `schema` whose references `validator` follows itself. +/// +/// `resolved` is `schema` with its references inlined, the shape whose +/// recursion bounds what explaining a rejection costs. Without it (a `$ref` +/// cycle defeats inlining), a rejection is left unexplained. +pub fn diagnose_resolved( + validator: &jsonschema::Validator, + schema: &Value, + resolved: Option<&Value>, + instance: &Value, +) -> Diagnosis { if validator.is_valid(instance) { return Diagnosis::default(); } - if !has_linear_recursion(schema) { + if !resolved.is_some_and(has_linear_recursion) { return Diagnosis { unexplained: Some( "the schema can re-enter itself in a shape where explaining a rejection costs \ @@ -200,78 +252,12 @@ fn is_valid_uuid(value: &str) -> bool { /// Checks ECMA 262 regex syntax (README sec 9.2, ADR-0005). #[must_use] fn is_valid_ecma262_regex(pattern: &str) -> bool { - // Bound the recursive parser's input and stack usage. - if pattern.len() > MAX_REGEX_LEN { - return false; - } - if pattern.len() <= REGEX_INLINE_LEN { - return regress::Regex::new(pattern).is_ok(); - } - parse_on_owned_stack(pattern) + pattern.len() <= MAX_REGEX_LEN && jsonschema_regex::is_valid_ecma_regex(pattern) } /// Longest accepted `format: regex` value. const MAX_REGEX_LEN: usize = 32 * 1024; -/// Patterns safe to parse on the smallest supported worker stack. -const REGEX_INLINE_LEN: usize = 512; - -/// Stack reserved for parsing larger patterns. -const REGEX_PARSE_STACK: usize = 64 * 1024 * 1024; - -struct RegexParseRequest { - pattern: String, - response: mpsc::SyncSender, -} - -static REGEX_WORKER: OnceLock>> = OnceLock::new(); - -/// Parses long patterns on a reusable large-stack worker; failures reject. -fn parse_on_owned_stack(pattern: &str) -> bool { - parse_with_regex_worker(pattern, regex_worker()) -} - -fn regex_worker() -> Option<&'static mpsc::SyncSender> { - REGEX_WORKER.get_or_init(start_regex_worker).as_ref() -} - -fn start_regex_worker() -> Option> { - let (sender, receiver) = mpsc::sync_channel::(0); - std::thread::Builder::new() - .name("gts-regex-parser".to_owned()) - .stack_size(REGEX_PARSE_STACK) - .spawn(move || { - while let Ok(request) = receiver.recv() { - let valid = - std::panic::catch_unwind(|| regress::Regex::new(&request.pattern).is_ok()) - .unwrap_or(false); - let _ = request.response.send(valid); - } - }) - .ok() - .map(|_| sender) -} - -fn parse_with_regex_worker( - pattern: &str, - worker: Option<&mpsc::SyncSender>, -) -> bool { - let Some(worker) = worker else { - return false; - }; - let (response, result) = mpsc::sync_channel(1); - if worker - .send(RegexParseRequest { - pattern: pattern.to_owned(), - response, - }) - .is_err() - { - return false; - } - result.recv().unwrap_or(false) -} - /// Renders validator errors in the reference implementation's format. /// /// Only type errors need rewriting; other messages may contain schema text. @@ -507,10 +493,7 @@ mod tests { #[test] fn regex_format_survives_a_flat_alternation_bomb() { let bomb = vec!["a"; 10_000].join("|"); - assert!( - bomb.len() > REGEX_INLINE_LEN, - "must take the owned-stack path" - ); + assert!(bomb.len() <= MAX_REGEX_LEN); assert!( is_valid_ecma262_regex(&bomb), "an alternation of literals is a valid pattern" @@ -523,23 +506,10 @@ mod tests { assert!(!is_valid_ecma262_regex(&oversized)); } - #[test] - fn regex_worker_unavailable_fails_closed() { - assert!(!parse_with_regex_worker("valid", None)); - } - - #[test] - fn regex_worker_disconnect_fails_closed() { - let (worker, receiver) = std::sync::mpsc::sync_channel(0); - drop(receiver); - - assert!(!parse_with_regex_worker("valid", Some(&worker))); - } - #[test] fn regex_format_parses_a_large_but_permitted_pattern() { let wide = vec!["ab"; 400].join("|"); - assert!(wide.len() > REGEX_INLINE_LEN && wide.len() <= MAX_REGEX_LEN); + assert!(wide.len() <= MAX_REGEX_LEN); assert!(is_valid_ecma262_regex(&wide)); let wide_invalid = format!("{wide}|("); diff --git a/gts/src/lib.rs b/gts/src/lib.rs index 25f0154..306a720 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -7,6 +7,7 @@ pub mod path_resolver; pub mod schema; pub mod schema_cast; pub mod schema_derivation; +mod schema_dialect; pub mod schema_evolution; pub mod schema_modifiers; pub mod schema_narrow; @@ -20,7 +21,7 @@ pub mod testing; pub mod x_gts_ref; /// GTS specification revision implemented by compatibility and validation logic. -pub const GTS_SPECIFICATION_VERSION: &str = "0.13"; +pub const GTS_SPECIFICATION_VERSION: &str = "0.14"; /// Version of this Rust implementation. pub const GTS_IMPLEMENTATION_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -54,4 +55,4 @@ pub use schema_traits::{GtsTraitsSchema, inline_traits_schema_of}; pub use store::{ GtsReader, GtsStore, GtsStoreQueryResult, ResolvedType, SchemaComparison, StoreError, }; -pub use x_gts_ref::{XGtsRefValidationError, XGtsRefValidator}; +pub use x_gts_ref::{GtsRefValidation, XGtsRefValidationError, XGtsRefValidator}; diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 801f46a..4ea87d0 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -12,6 +12,7 @@ use crate::schema_cast::GtsEntityCastResult; #[cfg(test)] use crate::schema_evolution::CompatibilityVerdict; use crate::store::{GtsStore, GtsStoreQueryResult, Registration}; +use crate::x_gts_ref::GtsRefValidation; /// `is_schema` is `Some(true)` for schema/type IDs (ending with `~`), /// `Some(false)` for instance IDs and wildcard patterns that match instances, @@ -179,11 +180,13 @@ pub struct GtsAddEntitiesResult { pub results: Vec, } +/// Outcome of registering one entry of a [`GtsOps::add_schemas`] batch. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GtsAddSchemaResult { pub ok: bool, - #[serde(skip_serializing_if = "String::is_empty")] - pub id: String, + /// The entry's GTS Type Identifier, when its `$id` declares one. + #[serde(skip_serializing_if = "Option::is_none")] + pub type_id: Option, #[serde(skip_serializing_if = "String::is_empty")] pub error: String, /// Machine-readable rejection reason, omitted from serialized responses. @@ -191,6 +194,13 @@ pub struct GtsAddSchemaResult { pub rejection: Option, } +/// Outcome of [`GtsOps::add_schemas`]: `ok` only when every entry registered. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GtsAddSchemasResult { + pub ok: bool, + pub results: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GtsExtractIdResult { pub id: String, @@ -352,6 +362,15 @@ impl GtsOps { } pub fn add_entity(&mut self, content: &Value, validate: bool) -> GtsAddEntityResult { + self.add_entity_with(content, validate, GtsRefValidation::default()) + } + + pub fn add_entity_with( + &mut self, + content: &Value, + validate: bool, + refs: GtsRefValidation, + ) -> GtsAddEntityResult { let entity = GtsEntity::new( None, None, @@ -431,7 +450,9 @@ impl GtsOps { // `validate_schema`, discarding the resolved artifacts. if entity.is_schema { let validation = if validate { - self.store.validate_schema(&entity_id).map(|_| ()) + self.store + .validate_schema_with(&entity_id, refs) + .map(|_| ()) } else { self.store.validate_schema_refs(&entity_id) }; @@ -448,7 +469,7 @@ impl GtsOps { // Instance validation when requested. if validate && !entity.is_schema - && let Err(e) = self.store.validate_instance(&entity_id) + && let Err(e) = self.store.validate_instance_with(&entity_id, refs) { return self.reject_registration( &entity, @@ -661,48 +682,39 @@ impl GtsOps { GtsAddEntitiesResult { ok, results } } - pub fn add_schema(&mut self, type_id: String, schema: &Value) -> GtsAddSchemaResult { - // The structural keyword guard `add_entity` applies is enforced at - // every ingest, and this route is one. Skipping it let `/type-schemas` - // admit schemas `/entities` refuses, leaving the store holding a - // document `validate_schema` then reports as invalid. Pure check, so it - // runs before `register_schema` for the same reason it does there. - if let Err(error) = crate::schema_modifiers::validate_gts_keywords(schema) { - return GtsAddSchemaResult { - ok: false, - id: String::new(), - error, - rejection: None, - }; - } + /// Registers a batch of GTS Type Schemas, each identified by its own `$id`. + /// + /// Every entry must be a canonical GTS Type Schema (README §2.4) and is then + /// registered exactly as [`Self::add_entity`] registers it, so both routes + /// 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(); + let ok = results.iter().all(|r| r.ok); + GtsAddSchemasResult { ok, results } + } - match self.store.register_schema(&type_id, schema) { - Ok(()) => GtsAddSchemaResult { - ok: true, - id: type_id, - error: String::new(), - rejection: None, - }, - Err(e) => GtsAddSchemaResult { - ok: false, - id: String::new(), - error: format!( - "Unable to register schema: {e}\n{}", - self.get_details(&GtsEntity::new( - None, - None, - schema, - Some(&self.cfg), - None, - false, - String::new(), - None, - None, - )) - ), - rejection: matches!(e, crate::store::StoreError::ImmutableConflict(_)) - .then_some(AddEntityRejection::Conflict), - }, + fn add_type_schema(&mut self, schema: &Value) -> GtsAddSchemaResult { + let type_id = match GtsStore::declared_type_id(schema) { + Ok(type_id) => type_id, + Err(error) => { + return GtsAddSchemaResult { + ok: false, + type_id: None, + error, + rejection: None, + }; + } + }; + let added = self.add_entity(schema, false); + GtsAddSchemaResult { + ok: added.ok, + type_id: Some(type_id), + error: added.error, + rejection: added.rejection, } } @@ -862,7 +874,15 @@ impl GtsOps { } pub fn validate_instance(&mut self, gts_id: &str) -> GtsValidationResult { - match self.store.validate_instance(gts_id) { + self.validate_instance_with(gts_id, GtsRefValidation::default()) + } + + pub fn validate_instance_with( + &mut self, + gts_id: &str, + refs: GtsRefValidation, + ) -> GtsValidationResult { + match self.store.validate_instance_with(gts_id, refs) { Ok(()) => GtsValidationResult { id: gts_id.to_owned(), ok: true, @@ -877,10 +897,18 @@ impl GtsOps { } pub fn validate_schema(&mut self, gts_id: &str) -> GtsValidationResult { + self.validate_schema_with(gts_id, GtsRefValidation::default()) + } + + pub fn validate_schema_with( + &mut self, + gts_id: &str, + refs: GtsRefValidation, + ) -> GtsValidationResult { // Full pipeline lives in `GtsStore::validate_schema` (refs → chain → // resolve → meta-compile → traits); we only need pass/fail here, so the // resolved artifacts are discarded. - match self.store.validate_schema(gts_id) { + match self.store.validate_schema_with(gts_id, refs) { Ok(_) => GtsValidationResult { id: gts_id.to_owned(), ok: true, @@ -895,8 +923,19 @@ impl GtsOps { } pub fn validate_entity(&mut self, gts_id: &str) -> GtsEntityValidationResult { + self.validate_entity_with(gts_id, GtsRefValidation::default()) + } + + pub fn validate_entity_with( + &mut self, + gts_id: &str, + refs: GtsRefValidation, + ) -> GtsEntityValidationResult { + // An anonymous instance is keyed by a UUID, which is no GTS id; it is + // still an instance, so only an unknown id is a parse failure. let parsed_id = match GtsId::try_new(gts_id) { - Ok(parsed_id) => parsed_id, + Ok(parsed_id) => Some(parsed_id), + Err(_) if self.store.get(gts_id).is_some() => None, Err(e) => { return GtsEntityValidationResult { id: gts_id.to_owned(), @@ -907,10 +946,13 @@ impl GtsOps { } }; - let (result, entity_type) = if parsed_id.is_type() { - (self.validate_schema(gts_id), "schema".to_owned()) + let (result, entity_type) = if parsed_id.is_some_and(|id| id.is_type()) { + (self.validate_schema_with(gts_id, refs), "schema".to_owned()) } else { - (self.validate_instance(gts_id), "instance".to_owned()) + ( + self.validate_instance_with(gts_id, refs), + "instance".to_owned(), + ) }; GtsEntityValidationResult { @@ -1156,7 +1198,7 @@ mod tests { }, "required": ["id"] }); - ops.add_schema("gts.test.base.v1.0~".to_owned(), &base_schema); + ops.add_schemas(std::slice::from_ref(&base_schema)); // Register a derived schema let derived_schema = json!({ @@ -1170,7 +1212,7 @@ mod tests { }, "required": ["id"] }); - ops.add_schema("gts.test.derived.v1.1~".to_owned(), &derived_schema); + ops.add_schemas(std::slice::from_ref(&derived_schema)); // Register an instance let instance = json!({ @@ -1387,7 +1429,7 @@ mod tests { } } }); - ops.add_schema("gts.test.compat.v1.0~".to_owned(), &old_schema); + ops.add_schemas(std::slice::from_ref(&old_schema)); // Register new schema with expanded enum let new_schema = json!({ @@ -1401,7 +1443,7 @@ mod tests { } } }); - ops.add_schema("gts.test.compat.v1.1~".to_owned(), &new_schema); + ops.add_schemas(std::slice::from_ref(&new_schema)); // Check compatibility - just verify the method executes let result = ops.compatibility("gts.test.compat.v1.0~", "gts.test.compat.v1.1~"); @@ -1703,7 +1745,7 @@ mod tests { let result = GtsAddSchemaResult { ok: true, - id: "gts.vendor.package.namespace.type.v1.0~".to_owned(), + type_id: Some("gts.vendor.package.namespace.type.v1.0~".to_owned()), error: String::new(), rejection: None, }; @@ -1711,9 +1753,19 @@ mod tests { let json = to_json_obj(&result); assert!(json.get("ok").expect("test").as_bool().expect("test")); assert_eq!( - json.get("id").expect("test").as_str().expect("test"), + json.get("type_id").expect("test").as_str().expect("test"), "gts.vendor.package.namespace.type.v1.0~" ); + assert!(json.get("error").is_none()); + assert!(json.get("rejection").is_none()); + + let unidentified = to_json_obj(&GtsAddSchemaResult { + ok: false, + type_id: None, + error: "no $id".to_owned(), + rejection: None, + }); + assert!(unidentified.get("type_id").is_none()); } #[test] @@ -2679,10 +2731,7 @@ mod tests { "type": "object" }); - ops.add_schema( - "gts.vendor.package.namespace.type.v1.0~".to_owned(), - &schema, - ); + assert!(ops.add_schemas(std::slice::from_ref(&schema)).ok); let result = ops.schema_graph("gts.vendor.package.namespace.type.v1.0~"); assert!(result.graph.is_object()); @@ -2848,10 +2897,7 @@ mod tests { } }); - ops.add_schema( - "gts.vendor.package.namespace.type.v1.0~".to_owned(), - &schema, - ); + assert!(ops.add_schemas(std::slice::from_ref(&schema)).ok); let content = json!({ "id": "gts.vendor.package.namespace.type.v1.0", @@ -2921,14 +2967,8 @@ mod tests { } }); - ops.add_schema( - "gts.vendor.package.namespace.type.v1.0~".to_owned(), - &schema1, - ); - ops.add_schema( - "gts.vendor.package.namespace.type.v1.1~".to_owned(), - &schema2, - ); + assert!(ops.add_schemas(std::slice::from_ref(&schema1)).ok); + assert!(ops.add_schemas(std::slice::from_ref(&schema2)).ok); let result = ops.compatibility( "gts.vendor.package.namespace.type.v1.0~", @@ -3600,29 +3640,36 @@ mod tests { } #[test] - fn test_add_schema_rejects_changed_content_for_a_registered_id() { + fn test_add_schemas_rejects_changed_content_for_a_registered_id() { let mut ops = GtsOps::new(None, None, 0); let type_id = "gts.x.rollback._.explicit.v1~"; let schema = |value_type: &str| { json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": format!("gts://{type_id}"), "type": "object", "properties": {"value": {"type": value_type}} }) }; - let first = ops.add_schema(type_id.to_owned(), &schema("string")); - assert!(first.ok, "{}", first.error); - assert_eq!(first.id, type_id); - assert!(first.rejection.is_none()); + let first = ops.add_schemas(&[schema("string")]); + assert!(first.ok, "{}", first.results[0].error); + assert_eq!(first.results[0].type_id.as_deref(), Some(type_id)); + assert!(first.results[0].rejection.is_none()); - let resubmitted = ops.add_schema(type_id.to_owned(), &schema("string")); + let resubmitted = ops.add_schemas(&[schema("string")]); assert!(resubmitted.ok, "identical content is accepted"); - assert!(resubmitted.rejection.is_none()); + assert!(resubmitted.results[0].rejection.is_none()); - let conflict = ops.add_schema(type_id.to_owned(), &schema("integer")); + let conflict = ops.add_schemas(&[schema("integer")]); assert!(!conflict.ok, "changed content must be refused"); - assert_eq!(conflict.rejection, Some(AddEntityRejection::Conflict)); - assert!(conflict.id.is_empty()); + let refused = &conflict.results[0]; + assert_eq!(refused.rejection, Some(AddEntityRejection::Conflict)); + assert_eq!( + refused.type_id.as_deref(), + Some(type_id), + "a refused entry still names the id it declared" + ); assert_eq!( ops.get_entity(type_id).content, Some(schema("string")), @@ -3631,15 +3678,20 @@ mod tests { } #[test] - fn test_add_schema_refuses_a_misplaced_keyword_like_add_entity() { + fn test_add_schemas_refuses_a_misplaced_keyword_like_add_entity() { let mut ops = GtsOps::new(None, None, 0); - let type_id = "gts.x.parity._.misplaced.v1~"; - let schema = json!({ - "type": "object", - "properties": {"a": {"type": "string", "x-gts-traits": {"k": "v"}}}, - }); + let schema = |type_id: &str| { + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": format!("gts://{type_id}"), + "type": "object", + "properties": {"a": {"type": "string", "x-gts-traits": {"k": "v"}}}, + }) + }; - let refused = ops.add_schema(type_id.to_owned(), &schema); + let type_id = "gts.x.parity._.misplaced.v1~"; + let batch = ops.add_schemas(&[schema(type_id)]); + let refused = &batch.results[0]; assert!(!refused.ok, "a misplaced trait keyword must be refused"); assert!( refused.rejection.is_none(), @@ -3655,17 +3707,66 @@ mod tests { ); // The same content through the other ingest gives the same verdict. - let via_entity = ops.add_entity( - &json!({ - "$id": "gts://gts.x.parity._.viaentity.v1~", + let via_entity = ops.add_entity(&schema("gts.x.parity._.viaentity.v1~"), false); + assert!(!via_entity.ok); + assert_eq!(via_entity.error, refused.error); + } + + #[test] + fn test_add_schemas_requires_a_canonical_identity() { + let mut ops = GtsOps::new(None, None, 0); + let draft_07 = "http://json-schema.org/draft-07/schema#"; + + let batch = ops.add_schemas(&[ + json!({"$id": "gts://gts.x.canon._.noschema.v1~", "type": "object"}), + json!({"$schema": draft_07, "type": "object"}), + json!({"$schema": draft_07, "$id": "https://example.com/order.json"}), + json!({"$schema": draft_07, "$id": "gts.x.canon._.bare.v1~"}), + json!({"$schema": draft_07, "$id": "gts://gts.x.canon._.type.v1~x.canon._.inst.v1"}), + json!(["not", "an", "object"]), + ]); + + assert!(!batch.ok); + let errors: Vec<&str> = batch.results.iter().map(|r| r.error.as_str()).collect(); + assert!(errors[0].contains("'$schema'"), "{}", errors[0]); + for error in &errors[1..5] { + assert!(error.contains("'$id'"), "{error}"); + } + assert!(errors[5].contains("JSON object"), "{}", errors[5]); + for result in &batch.results { + assert!(!result.ok); + assert!(result.type_id.is_none(), "{result:?}"); + } + assert!( + ops.get_entity("gts.x.canon._.noschema.v1~") + .content + .is_none(), + "a document without $schema is not a GTS Type Schema" + ); + } + + #[test] + fn test_add_schemas_registers_valid_entries_alongside_rejected_ones() { + let mut ops = GtsOps::new(None, None, 0); + let type_id = "gts.x.canon._.batchok.v1~"; + + let batch = ops.add_schemas(&[ + json!({ "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": {"a": {"type": "string", "x-gts-traits": {"k": "v"}}}, + "$id": format!("gts://{type_id}"), + "type": "object" }), - false, + json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}), + ]); + + assert!(!batch.ok, "one entry was rejected"); + assert!(batch.results[0].ok, "{}", batch.results[0].error); + assert_eq!(batch.results[0].type_id.as_deref(), Some(type_id)); + assert!(!batch.results[1].ok); + assert!( + ops.get_entity(type_id).ok, + "the valid entry must be registered" ); - assert!(!via_entity.ok); - assert_eq!(via_entity.error, refused.error); } #[test] diff --git a/gts/src/schema_dialect.rs b/gts/src/schema_dialect.rs new file mode 100644 index 0000000..779f83b --- /dev/null +++ b/gts/src/schema_dialect.rs @@ -0,0 +1,181 @@ +//! The JSON Schema dialects a GTS Type Schema may declare (README §11.0). +//! +//! GTS admits Draft-07, Draft 2019-09 and Draft 2020-12, nothing older and no +//! custom meta-schema. JSON Schema lets a subschema switch dialect with its +//! own `$schema`, and resources that declare different dialects reference +//! each other; GTS allows neither, so no part of a type is read under a +//! vocabulary other than the one its hierarchy selects. + +use jsonschema::Draft; +use serde_json::Value; + +use crate::gts::GTS_ID_URI_PREFIX; +use crate::schema_modifiers::SchemaScope; +use crate::schema_resolver::SchemaProvider; + +/// The dialect a GTS Type Schema declares in its top-level `$schema`. +/// +/// A GTS Type Schema always declares one (README §2.4). The `http` and `https` +/// spellings and an empty trailing fragment are equivalent; anything else +/// outside the supported set is refused rather than read as some fallback +/// dialect. +/// +/// # Errors +/// Why the document declares no supported dialect. +pub fn document_dialect(schema: &Value) -> Result { + let Some(declared) = schema.get("$schema") else { + return Err("a GTS Type Schema must declare a top-level '$schema'".to_owned()); + }; + let Some(uri) = declared.as_str() else { + return Err(format!("'$schema' must be a string, got {declared}")); + }; + let unfragmented = uri.strip_suffix('#').unwrap_or(uri); + let location = unfragmented + .strip_prefix("https://") + .or_else(|| unfragmented.strip_prefix("http://")); + match location { + Some("json-schema.org/draft-07/schema") => Ok(Draft::Draft7), + Some("json-schema.org/draft/2019-09/schema") => Ok(Draft::Draft201909), + Some("json-schema.org/draft/2020-12/schema") => Ok(Draft::Draft202012), + Some(location) if is_pre_draft_07(location) => Err(format!( + "'$schema' declares '{uri}', but Draft-07 is the minimum supported dialect" + )), + _ => Err(format!( + "'$schema' declares '{uri}', which is not a supported dialect \ + (Draft-07, Draft 2019-09 or Draft 2020-12)" + )), + } +} + +/// Whether `location` is the meta-schema of Draft 6 or an earlier draft. +fn is_pre_draft_07(location: &str) -> bool { + location + .strip_prefix("json-schema.org/draft-0") + .and_then(|rest| rest.strip_suffix("/schema")) + .is_some_and(|number| matches!(number, "0" | "1" | "2" | "3" | "4" | "5" | "6")) +} + +/// Human-readable dialect name for diagnostics. +pub fn dialect_name(draft: Draft) -> &'static str { + match draft { + Draft::Draft4 => "Draft 4", + Draft::Draft6 => "Draft 6", + Draft::Draft7 => "Draft-07", + Draft::Draft201909 => "Draft 2019-09", + Draft::Draft202012 => "Draft 2020-12", + _ => "an unrecognized dialect", + } +} + +/// Rejects a `$ref` whose target is read under a different dialect than the +/// reference itself. +/// +/// Covers same-document JSON Pointers (including ones into an embedded +/// resource that declares its own `$schema`) and `gts://` targets with or +/// without a fragment. A same-document pointer resolves from the root of the +/// schema resource it sits in, so `#` inside an embedded resource with its own +/// `$id` names that resource. A target that does not resolve is skipped: +/// reference resolution reports it. +/// +/// # Errors +/// The first cross-dialect `$ref`, by location. +pub fn check_references(schema: &Value, provider: &dyn SchemaProvider) -> Result<(), String> { + let mut mismatch = None; + crate::schema_modifiers::for_each_schema_node_in_scope(schema, &mut |node, path, scope| { + if mismatch.is_some() { + return; + } + let Some(reference) = node.get("$ref").and_then(Value::as_str) else { + return; + }; + let Some(target) = target_dialect(scope, reference, provider) else { + return; + }; + let dialect = scope.dialect; + if target != dialect { + let location = if path.is_empty() { + "$ref".to_owned() + } else { + format!("{path}/$ref") + }; + mismatch = Some(format!( + "'{location}' ('{reference}') is read under {} but its target declares {}; \ + a $ref must not cross dialects", + dialect_name(dialect), + dialect_name(target) + )); + } + }); + mismatch.map_or(Ok(()), Err) +} + +/// Rejects a subschema read under another dialect than `dialect`, the one +/// `schema` declares. +/// +/// JSON Schema lets any subschema, an embedded resource or not, switch +/// dialect with its own `$schema`. GTS reads every part of a type, its trait +/// schema included, under the dialect of its hierarchy, so a nested `$schema` +/// may only restate that dialect. +/// +/// # Errors +/// The first subschema that switches dialect, by location. +pub fn check_subschemas(schema: &Value, dialect: Draft) -> Result<(), String> { + let mut mismatch = None; + crate::schema_modifiers::for_each_schema_node_in_scope(schema, &mut |_, path, scope| { + if mismatch.is_none() && scope.dialect != dialect { + mismatch = Some(format!( + "'{path}' declares {} but the type is read under {}; \ + a subschema must not change dialect", + dialect_name(scope.dialect), + dialect_name(dialect) + )); + } + }); + mismatch.map_or(Ok(()), Err) +} + +/// The dialect in effect at the target of `reference`, made from a node in +/// `scope`, when it resolves. +fn target_dialect( + scope: SchemaScope<'_>, + reference: &str, + provider: &dyn SchemaProvider, +) -> Option { + match reference.strip_prefix(GTS_ID_URI_PREFIX) { + Some(target) => { + let (id, pointer) = target.split_once('#').unwrap_or((target, "")); + let document = provider.schema_content(id)?; + dialect_at(document, Draft::default().detect(document), pointer) + } + None => dialect_at( + scope.resource, + scope.resource_dialect, + reference.strip_prefix('#')?, + ), + } +} + +/// The dialect in effect at `pointer` below `resource`, which is read under +/// `dialect`: the nearest `$schema` on the way down. +fn dialect_at(resource: &Value, dialect: Draft, pointer: &str) -> Option { + let mut dialect = dialect; + if pointer.is_empty() { + return Some(dialect); + } + let mut node = resource; + for token in pointer.strip_prefix('/')?.split('/') { + let token = token.replace("~1", "/").replace("~0", "~"); + node = match node { + Value::Object(map) => map.get(&token)?, + Value::Array(items) => items.get(token.parse::().ok()?)?, + _ => return None, + }; + dialect = dialect.detect(node); + } + Some(dialect) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +#[path = "schema_dialect_test.rs"] +mod schema_dialect_test; diff --git a/gts/src/schema_dialect_test.rs b/gts/src/schema_dialect_test.rs new file mode 100644 index 0000000..aee6875 --- /dev/null +++ b/gts/src/schema_dialect_test.rs @@ -0,0 +1,336 @@ +//! Unit tests for JSON Schema dialect selection and reference boundaries. + +use super::*; +use serde_json::json; +use std::collections::HashMap; + +struct Schemas(HashMap<&'static str, Value>); + +impl SchemaProvider for Schemas { + fn schema_content(&self, type_id: &str) -> Option<&Value> { + self.0.get(type_id) + } +} + +fn no_schemas() -> Schemas { + Schemas(HashMap::new()) +} + +#[test] +fn supported_dialects_accept_equivalent_spellings() { + for (uri, expected) in [ + ("http://json-schema.org/draft-07/schema#", Draft::Draft7), + ("https://json-schema.org/draft-07/schema", Draft::Draft7), + ( + "https://json-schema.org/draft/2019-09/schema", + Draft::Draft201909, + ), + ( + "http://json-schema.org/draft/2019-09/schema#", + Draft::Draft201909, + ), + ( + "https://json-schema.org/draft/2020-12/schema", + Draft::Draft202012, + ), + ( + "http://json-schema.org/draft/2020-12/schema#", + Draft::Draft202012, + ), + ] { + assert_eq!( + document_dialect(&json!({"$schema": uri})), + Ok(expected), + "{uri}" + ); + } +} + +#[test] +fn an_undeclared_dialect_is_refused() { + let error = document_dialect(&json!({"type": "object"})).unwrap_err(); + assert!(error.contains("'$schema'"), "{error}"); +} + +#[test] +fn pre_draft_07_dialects_are_refused_as_too_old() { + for uri in [ + "http://json-schema.org/draft-06/schema#", + "http://json-schema.org/draft-04/schema#", + "https://json-schema.org/draft-03/schema", + ] { + let error = document_dialect(&json!({"$schema": uri})).unwrap_err(); + assert!( + error.contains("minimum supported dialect"), + "{uri}: {error}" + ); + } +} + +#[test] +fn unrecognized_dialects_are_refused() { + for declared in [ + json!("https://example.invalid/not-a-json-schema-dialect"), + json!("https://json-schema.org/draft/2020-21/schema"), + json!("https://json-schema.org/draft/2020-12/schema##"), + json!("ftp://json-schema.org/draft-07/schema#"), + json!(""), + ] { + let error = document_dialect(&json!({"$schema": declared})).unwrap_err(); + assert!( + error.contains("not a supported dialect"), + "{declared}: {error}" + ); + } + let error = document_dialect(&json!({"$schema": 7})).unwrap_err(); + assert!(error.contains("must be a string"), "{error}"); +} + +#[test] +fn a_local_ref_into_an_embedded_resource_of_another_dialect_is_refused() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {"legacy": {"$ref": "#/$defs/legacy"}}, + "$defs": { + "legacy": { + "$id": "legacy", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" + } + } + }); + let error = check_references(&schema, &no_schemas()).unwrap_err(); + assert!(error.contains("'properties/legacy/$ref'"), "{error}"); + assert!(error.contains("Draft 2020-12"), "{error}"); + assert!(error.contains("Draft-07"), "{error}"); +} + +#[test] +fn a_local_ref_inside_an_embedded_resource_resolves_from_that_resource() { + // `#` inside `legacy` names `legacy`, not the document, so none of these + // references leave Draft-07 - even though the document root is 2020-12 + // and also has a `definitions/name` for the pointer to land on. + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "definitions": {"name": {"type": "string"}}, + "$defs": { + "legacy": { + "$id": "legacy", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "next": {"$ref": "#"}, + "name": {"$ref": "#/definitions/name"}, + "inner": {"$ref": "#/definitions/inner"} + }, + "definitions": { + "name": {"type": "string"}, + "inner": { + "$id": "inner", + "properties": {"again": {"$ref": "#"}} + } + } + } + } + }); + assert_eq!(check_references(&schema, &no_schemas()), Ok(())); +} + +#[test] +fn a_local_ref_inside_an_embedded_resource_is_still_checked() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "legacy": { + "$id": "legacy", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {"modern": {"$ref": "#/definitions/modern"}}, + "definitions": { + "modern": { + "$id": "modern", + "$schema": "https://json-schema.org/draft/2020-12/schema" + } + } + } + } + }); + let error = check_references(&schema, &no_schemas()).unwrap_err(); + assert!( + error.contains("'$defs/legacy/properties/modern/$ref'"), + "{error}" + ); + assert!(error.contains("read under Draft-07"), "{error}"); +} + +#[test] +fn a_draft_07_id_beside_a_ref_starts_no_resource() { + // Draft-07 ignores every sibling of `$ref`, `$id` included, so this + // `#` still names the 2020-12 document root. + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "legacy": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {"self": {"$id": "ignored", "$ref": "#"}} + }, + "modern": {"$id": "modern", "$ref": "#"} + } + }); + let error = check_references(&schema, &no_schemas()).unwrap_err(); + assert!( + error.contains("'properties/legacy/properties/self/$ref'"), + "{error}" + ); +} + +#[test] +fn a_local_ref_within_one_dialect_is_accepted() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {"name": {"$ref": "#/$defs/name"}}, + "$defs": {"name": {"type": "string"}} + }); + assert_eq!(check_references(&schema, &no_schemas()), Ok(())); +} + +#[test] +fn an_unreferenced_embedded_resource_is_left_to_the_subschema_check() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "legacy": { + "$id": "legacy", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" + } + } + }); + assert_eq!(check_references(&schema, &no_schemas()), Ok(())); + assert!(check_subschemas(&schema, Draft::Draft202012).is_err()); +} + +#[test] +fn a_gts_ref_must_target_the_same_dialect() { + let schemas = Schemas(HashMap::from([( + "gts.x.dialect._.base.v1~", + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "legacy": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" + } + } + }), + )])); + let referrer = |dialect: &str, reference: &str| json!({"$schema": dialect, "allOf": [{"$ref": reference}]}); + let draft_07 = "http://json-schema.org/draft-07/schema#"; + let draft_2020 = "https://json-schema.org/draft/2020-12/schema"; + + let error = check_references( + &referrer(draft_07, "gts://gts.x.dialect._.base.v1~"), + &schemas, + ) + .unwrap_err(); + assert!(error.contains("'allOf[0]/$ref'"), "{error}"); + assert_eq!( + check_references( + &referrer(draft_2020, "gts://gts.x.dialect._.base.v1~"), + &schemas + ), + Ok(()) + ); + assert!( + check_references( + &referrer(draft_2020, "gts://gts.x.dialect._.base.v1~#/$defs/legacy"), + &schemas + ) + .is_err(), + "a fragment into an embedded resource is read in that resource's dialect" + ); +} + +#[test] +fn an_unresolved_ref_is_left_to_resolution() { + let schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "missing": {"$ref": "#/definitions/missing"}, + "absent": {"$ref": "gts://gts.x.dialect._.absent.v1~"} + } + }); + assert_eq!(check_references(&schema, &no_schemas()), Ok(())); +} + +#[test] +fn a_ref_inside_the_trait_schema_is_checked() { + let schemas = Schemas(HashMap::from([( + "gts.x.dialect._.traits.v1~", + json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object"}), + )])); + let schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "x-gts-traits-schema": {"$ref": "gts://gts.x.dialect._.traits.v1~"} + }); + let error = check_references(&schema, &schemas).unwrap_err(); + assert!(error.contains("'x-gts-traits-schema/$ref'"), "{error}"); +} + +#[test] +fn a_subschema_of_another_dialect_is_refused() { + let draft_07 = json!("http://json-schema.org/draft-07/schema#"); + for (location, schema) in [ + ( + "x-gts-traits-schema", + json!({"x-gts-traits-schema": { + "$id": "https://example.com/gts/legacy-traits", + "$schema": draft_07, + "type": "object" + }}), + ), + ( + "x-gts-traits-schema/properties/limits", + json!({"x-gts-traits-schema": { + "properties": {"limits": {"$schema": draft_07}} + }}), + ), + ( + "$defs/legacy", + json!({"$defs": {"legacy": {"$id": "legacy", "$schema": draft_07}}}), + ), + ( + "allOf[0]/properties/legacy", + json!({"allOf": [{"properties": {"legacy": {"$schema": draft_07}}}]}), + ), + ] { + let mut schema = schema; + schema["$schema"] = json!("https://json-schema.org/draft/2020-12/schema"); + let error = check_subschemas(&schema, Draft::Draft202012).unwrap_err(); + assert!(error.contains(&format!("'{location}'")), "{error}"); + assert!(error.contains("declares Draft-07"), "{error}"); + assert!(error.contains("read under Draft 2020-12"), "{error}"); + } + + let unrecognized = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {"custom": {"$schema": "https://example.invalid/meta"}} + }); + let error = check_subschemas(&unrecognized, Draft::Draft7).unwrap_err(); + assert!(error.contains("'properties/custom'"), "{error}"); +} + +#[test] +fn a_subschema_restating_the_dialect_is_accepted() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "restated": {"$schema": "http://json-schema.org/draft/2020-12/schema#"}, + "resource": {"$id": "resource", "type": "string"}, + // Data, not a subschema: its `$schema` selects nothing. + "literal": {"const": {"$schema": "http://json-schema.org/draft-07/schema#"}} + }, + "x-gts-traits-schema": true, + "x-gts-traits": {"$schema": "http://json-schema.org/draft-07/schema#"} + }); + assert_eq!(check_subschemas(&schema, Draft::Draft202012), Ok(())); +} diff --git a/gts/src/schema_modifiers.rs b/gts/src/schema_modifiers.rs index d07b690..916447d 100644 --- a/gts/src/schema_modifiers.rs +++ b/gts/src/schema_modifiers.rs @@ -36,7 +36,7 @@ pub fn validate_schema_modifiers(content: &Value) -> Result<(), String> { )); } - visit_schema_nodes(content, "", EnterTraitSchema::Yes, &mut |map, path| { + visit_schema_nodes(content, "", EnterTraitSchema::Yes, &mut |map, path, _| { if path.is_empty() { return Ok(()); } @@ -58,7 +58,7 @@ pub fn validate_schema_modifiers(content: &Value) -> Result<(), String> { /// # Errors /// Returns an error describing the first misplaced keyword found. pub fn validate_trait_placement(content: &Value) -> Result<(), String> { - visit_schema_nodes(content, "", EnterTraitSchema::No, &mut |map, path| { + visit_schema_nodes(content, "", EnterTraitSchema::No, &mut |map, path, _| { if path.is_empty() { return Ok(()); } @@ -113,10 +113,52 @@ const KNOWN_GTS_KEYWORDS: &[&str] = &[ X_GTS_REF, ]; +/// Where a schema node sits: the dialect it is read under, and the schema +/// resource a same-document reference (`#...`) in it resolves from. +#[derive(Debug, Clone, Copy)] +pub(crate) struct SchemaScope<'a> { + /// The dialect in effect at the node. + pub(crate) dialect: Draft, + /// The root of the innermost schema resource holding the node: the + /// document, or a subschema whose `$id` the dialect honours. + pub(crate) resource: &'a Value, + /// The dialect in effect at `resource`. + pub(crate) resource_dialect: Draft, +} + +impl<'a> SchemaScope<'a> { + fn document(root: &'a Value) -> Self { + let dialect = Draft::default().detect(root); + Self { + dialect, + resource: root, + resource_dialect: dialect, + } + } + + /// The scope of `subschema`, reached from a node in this scope. + /// + /// Draft-07 ignores an `$id` beside `$ref` and treats `#name` as an + /// anchor; neither starts a resource there. + fn enter(self, subschema: &'a Value) -> Self { + let dialect = self.dialect.detect(subschema); + if dialect.create_resource_ref(subschema).id().is_some() { + Self { + dialect, + resource: subschema, + resource_dialect: dialect, + } + } else { + Self { dialect, ..self } + } + } +} + type SchemaNodeVisitor<'a> = - dyn FnMut(&serde_json::Map, &str) -> Result<(), String> + 'a; + dyn FnMut(&serde_json::Map, &str, SchemaScope<'_>) -> Result<(), String> + 'a; type SchemaNodePredicate<'a> = dyn FnMut(&Map) -> bool + 'a; type SchemaNodeWalker<'a> = dyn FnMut(&Map, &str) + 'a; +type ScopedSchemaNodeWalker<'a> = dyn FnMut(&Map, &str, SchemaScope<'_>) + 'a; /// Visits the document and dialect-defined subschemas, excluding annotation data. fn visit_schema_nodes( @@ -125,36 +167,83 @@ fn visit_schema_nodes( enter_trait_schema: EnterTraitSchema, visit: &mut SchemaNodeVisitor<'_>, ) -> Result<(), String> { - visit_schema_nodes_with_draft(node, path, Draft::default(), enter_trait_schema, visit) + visit_schema_nodes_in_scope( + node, + path, + SchemaScope::document(node), + enter_trait_schema, + visit, + ) } -fn visit_schema_nodes_with_draft( - node: &Value, +fn visit_schema_nodes_in_scope<'a>( + node: &'a Value, path: &str, - inherited_draft: Draft, + scope: SchemaScope<'a>, enter_trait_schema: EnterTraitSchema, visit: &mut SchemaNodeVisitor<'_>, ) -> Result<(), String> { let Value::Object(map) = node else { return Ok(()); }; - let draft = inherited_draft.detect(node); - visit(map, path)?; + visit(map, path, scope)?; - let subresources = direct_subresources(node, draft, enter_trait_schema); - visit_direct_subresources(node, path, &subresources, draft, enter_trait_schema, visit) + let subresources = direct_subresources(node, scope.dialect, enter_trait_schema); + visit_direct_subresources(node, path, &subresources, scope, enter_trait_schema, visit) } /// Visits schema nodes with their path (`""` for the root). pub(crate) fn for_each_schema_node(node: &Value, visit: &mut SchemaNodeWalker<'_>) { - let result = visit_schema_nodes(node, "", EnterTraitSchema::Yes, &mut |map, path| { - visit(map, path); + for_each_schema_node_in_scope(node, &mut |map, path, _| visit(map, path)); +} + +/// Visits schema nodes with their path and their [`SchemaScope`]. +pub(crate) fn for_each_schema_node_in_scope(node: &Value, visit: &mut ScopedSchemaNodeWalker<'_>) { + let result = visit_schema_nodes(node, "", EnterTraitSchema::Yes, &mut |map, path, scope| { + visit(map, path, scope); Ok(()) }); debug_assert!(result.is_ok()); } +/// The subschemas of `document` that start a schema resource of their own, +/// by identity: those whose `$id` the dialect honours (see [`SchemaScope`]). +/// +/// A same-document reference (`#...`) inside one resolves from it rather than +/// from `document`. +pub(crate) fn embedded_resources( + document: &Value, +) -> std::collections::HashSet<*const Map> { + let mut found = std::collections::HashSet::new(); + if !has_nested_id(document) { + return found; + } + for_each_schema_node_in_scope(document, &mut |node, _, scope| { + let starts_here = scope + .resource + .as_object() + .is_some_and(|resource| std::ptr::eq(resource, node)); + if starts_here && !std::ptr::eq(scope.resource, document) { + found.insert(std::ptr::from_ref(node)); + } + }); + found +} + +/// Whether any object below `document`'s root declares an `$id`: only then +/// can a resource be embedded in it. +fn has_nested_id(document: &Value) -> bool { + let children: Box> = match document { + Value::Object(map) => Box::new(map.values()), + Value::Array(items) => Box::new(items.iter()), + _ => return false, + }; + children + .into_iter() + .any(|child| child.get("$id").is_some() || has_nested_id(child)) +} + /// Whether `node` contains a local JSON Pointer reference. fn is_local_ref(node: &Map) -> bool { node.get("$ref") @@ -170,7 +259,7 @@ pub(crate) fn contains_local_ref(schema: &Value) -> bool { /// Whether any schema node satisfies `predicate`. pub(crate) fn any_schema_node(node: &Value, predicate: &mut SchemaNodePredicate<'_>) -> bool { let mut found = false; - let result = visit_schema_nodes(node, "", EnterTraitSchema::Yes, &mut |map, _| { + let result = visit_schema_nodes(node, "", EnterTraitSchema::Yes, &mut |map, _, _| { if !found { found = predicate(map); } @@ -180,16 +269,22 @@ pub(crate) fn any_schema_node(node: &Value, predicate: &mut SchemaNodePredicate< found } -fn visit_direct_subresources( - value: &Value, +fn visit_direct_subresources<'a>( + value: &'a Value, path: &str, subresources: &[&Value], - draft: Draft, + scope: SchemaScope<'a>, enter_trait_schema: EnterTraitSchema, visit: &mut SchemaNodeVisitor<'_>, ) -> Result<(), String> { if is_direct_subresource(value, subresources) { - return visit_schema_nodes_with_draft(value, path, draft, enter_trait_schema, visit); + return visit_schema_nodes_in_scope( + value, + path, + scope.enter(value), + enter_trait_schema, + visit, + ); } match value { @@ -199,7 +294,7 @@ fn visit_direct_subresources( child, &extend_path(path, key), subresources, - draft, + scope, enter_trait_schema, visit, )?; @@ -211,7 +306,7 @@ fn visit_direct_subresources( child, &format!("{path}[{index}]"), subresources, - draft, + scope, enter_trait_schema, visit, )?; @@ -236,7 +331,7 @@ fn extend_path(path: &str, segment: &str) -> String { /// # Errors /// Returns an error naming the first unrecognised keyword and its location. fn validate_known_gts_keywords(content: &Value) -> Result<(), String> { - visit_schema_nodes(content, "", EnterTraitSchema::Yes, &mut |map, path| { + visit_schema_nodes(content, "", EnterTraitSchema::Yes, &mut |map, path, _| { for key in map.keys() { if key.starts_with("x-gts-") && !KNOWN_GTS_KEYWORDS.contains(&key.as_str()) { let location = if path.is_empty() { @@ -1175,7 +1270,7 @@ mod tests { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "allOf": [ - {"$ref": "gts.x.testmod.abs.concinst.v1~"}, + {"$ref": "gts://gts.x.testmod.abs.concinst.v1~"}, {"type": "object"}, ], }), @@ -1218,7 +1313,7 @@ mod tests { "type": "object", "x-gts-abstract": true, "allOf": [ - {"$ref": "gts.x.testmod.abs.chain.v1~"}, + {"$ref": "gts://gts.x.testmod.abs.chain.v1~"}, {"type": "object"}, ], }), @@ -1230,7 +1325,7 @@ mod tests { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "allOf": [ - {"$ref": "gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~"}, + {"$ref": "gts://gts.x.testmod.abs.chain.v1~x.testmod._.mid.v1~"}, {"type": "object"}, ], }), @@ -1315,7 +1410,7 @@ mod tests { "type": "object", "x-gts-final": true, "allOf": [ - {"$ref": "gts.x.testmod.absfinal.base.v1~"}, + {"$ref": "gts://gts.x.testmod.absfinal.base.v1~"}, {"type": "object", "properties": {"extra": {"type": "string"}}}, ], }), @@ -1349,7 +1444,7 @@ mod tests { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "allOf": [ - {"$ref": "gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~"}, + {"$ref": "gts://gts.x.testmod.absfinal.base.v1~x.testmod._.concrete.v1~"}, {"type": "object"}, ], }), diff --git a/gts/src/schema_resolver.rs b/gts/src/schema_resolver.rs index e238332..ac4f9b2 100644 --- a/gts/src/schema_resolver.rs +++ b/gts/src/schema_resolver.rs @@ -9,7 +9,10 @@ //! on `GtsStore` directly; the store implements `SchemaProvider` and exposes //! `resolve_schema_refs` as a thin wrapper. -use serde_json::Value; +use std::cell::RefCell; +use std::collections::HashMap; + +use serde_json::{Map, Value}; use crate::gts::GTS_ID_URI_PREFIX; @@ -33,11 +36,21 @@ const MAX_REF_CHAIN_DEPTH: usize = 32; /// Inlines `$ref`s in a JSON Schema using a [`SchemaProvider`] for lookups. pub(crate) struct SchemaResolver<'a> { provider: &'a dyn SchemaProvider, + /// Every document the current resolution has met, and every resource + /// embedded in one, mapped to its document. A same-document reference + /// inside an embedded resource resolves from that resource. + documents: RefCell>, } +/// A JSON object, by identity. +type ObjectKey = *const Map; + impl<'a> SchemaResolver<'a> { pub(crate) fn new(provider: &'a dyn SchemaProvider) -> Self { - Self { provider } + Self { + provider, + documents: RefCell::default(), + } } /// Strict `$ref` resolution that returns an error if any supported local @@ -56,6 +69,8 @@ impl<'a> SchemaResolver<'a> { /// Pointer or external GTS `$ref` cannot be resolved, or /// [`StoreError::CircularRef`] if a circular `$ref` is detected. pub(crate) fn resolve(&self, schema: &Value) -> Result { + self.documents.borrow_mut().clear(); + self.register_document(schema); let mut visited = std::collections::HashSet::new(); let mut cycle_found = false; let mut unresolved_refs = Vec::new(); @@ -89,6 +104,11 @@ impl<'a> SchemaResolver<'a> { // Recursively resolve $ref references in the schema match schema { Value::Object(map) => { + let local_root = if self.is_embedded(schema) { + schema + } else { + local_root + }; if let Some(Value::String(ref_uri)) = map.get("$ref") { // Handle internal JSON Schema references like #/$defs/GtsInstanceId // These should be inlined to match schemars 0.8 behavior (is_referenceable=false) @@ -104,8 +124,9 @@ impl<'a> SchemaResolver<'a> { let local_ref_key = format!("local:{:p}:{ref_uri}", std::ptr::from_ref(local_root)); if visited.contains(&local_ref_key) { - // Only root recursion keeps the same anchor. - if !std::ptr::eq(local_root, root_doc) { + // Only recursion inside the document being + // resolved keeps the same anchor. + if !self.is_in(local_root, root_doc) { *cycle_found = true; } return Value::Object(map.clone()); @@ -117,11 +138,13 @@ impl<'a> SchemaResolver<'a> { )); return Value::Object(map.clone()); } - if let Some(target) = local_root.pointer(pointer) { + if let Some((target, target_root)) = + self.locate(local_root, pointer) + { visited.insert(local_ref_key.clone()); let resolved = self.resolve_inner( target, - local_root, + target_root, root_doc, visited, cycle_found, @@ -215,20 +238,20 @@ impl<'a> SchemaResolver<'a> { // Try to resolve the reference using the canonical ID if let Some(content) = self.provider.schema_content(lookup_ref) { + self.register_document(content); let target_content = match pointer_fragment { - Some("") => Some(content), - Some(pointer) => content.pointer(pointer), + Some(pointer) => self.locate(content, pointer), None if canonical_ref.contains('#') => None, - None => Some(content), + None => Some((content, content)), }; - if let Some(target_content) = target_content { + if let Some((target_content, target_root)) = target_content { // Mark as visited before recursing visited.insert(canonical_ref.to_owned()); // Recursively resolve refs in the referenced schema let mut resolved = self.resolve_inner( target_content, - content, + target_root, root_doc, visited, cycle_found, @@ -399,6 +422,72 @@ impl<'a> SchemaResolver<'a> { other => other, } } + + /// Records `document` and the resources embedded in it, once. + fn register_document(&self, document: &Value) { + let Some(key) = object_key(document) else { + return; + }; + let mut documents = self.documents.borrow_mut(); + if documents.insert(key, key).is_some() { + return; + } + for resource in crate::schema_modifiers::embedded_resources(document) { + documents.insert(resource, key); + } + } + + /// Whether `node` is a resource embedded in a document. + fn is_embedded(&self, node: &Value) -> bool { + object_key(node).is_some_and(|key| { + self.documents + .borrow() + .get(&key) + .is_some_and(|document| *document != key) + }) + } + + /// Whether `resource` is `document` or embedded in it. + fn is_in(&self, resource: &Value, document: &Value) -> bool { + std::ptr::eq(resource, document) + || object_key(resource).is_some_and(|key| { + self.documents.borrow().get(&key).copied() == object_key(document) + }) + } + + /// The node JSON Pointer `pointer` names inside `resource`, and the + /// resource its own same-document references resolve from: the innermost + /// one on the way there. + fn locate<'v>(&self, resource: &'v Value, pointer: &str) -> Option<(&'v Value, &'v Value)> { + let mut node = resource; + let mut innermost = resource; + if !pointer.is_empty() { + for token in pointer.strip_prefix('/')?.split('/') { + let token = token.replace("~1", "/").replace("~0", "~"); + node = match node { + Value::Object(map) => map.get(&token)?, + Value::Array(items) => items.get(array_index(&token)?)?, + _ => return None, + }; + if self.is_embedded(node) { + innermost = node; + } + } + } + Some((node, innermost)) + } +} + +fn object_key(value: &Value) -> Option { + value.as_object().map(std::ptr::from_ref) +} + +/// An array index token, read as `serde_json`'s own pointer lookup reads one. +fn array_index(token: &str) -> Option { + if token.starts_with('+') || (token.starts_with('0') && token.len() != 1) { + return None; + } + token.parse().ok() } #[cfg(test)] diff --git a/gts/src/schema_resolver_test.rs b/gts/src/schema_resolver_test.rs index aaf8189..614a812 100644 --- a/gts/src/schema_resolver_test.rs +++ b/gts/src/schema_resolver_test.rs @@ -923,3 +923,40 @@ fn test_referring_definitions_do_not_replace_the_target_s_own() { crate::json_schema::validator_for(&resolved).expect("the resolved document must still compile"); } + +#[test] +fn test_resolve_reads_a_pointer_inside_an_embedded_resource_from_that_resource() { + // `#/definitions/n` inside `legacy` names legacy's own definition, whether + // the document is resolved itself or inlined through a `gts://` reference. + let lib = json!({ + "$id": "gts://gts.x.test.embedded.lib.v1~", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "definitions": {"n": {"type": "string"}}, + "properties": {"legacy": { + "$id": "legacy", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": {"n": {"$ref": "#/definitions/n"}}, + "definitions": {"n": {"type": "integer"}} + }} + }); + let p = MapProvider::new().with("gts.x.test.embedded.lib.v1~", lib.clone()); + + let on_its_own = resolve(&p, lib); + assert_eq!( + on_its_own.pointer("/properties/legacy/properties/n"), + Some(&json!({"type": "integer"})) + ); + + let inlined = resolve( + &p, + json!({ + "$id": "gts://gts.x.test.embedded.holder.v1~", + "properties": {"item": {"$ref": "gts://gts.x.test.embedded.lib.v1~#/properties/legacy"}} + }), + ); + assert_eq!( + inlined.pointer("/properties/item/properties/n"), + Some(&json!({"type": "integer"})), + "{inlined}" + ); +} diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index 1b6ff47..aea1307 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -73,9 +73,18 @@ pub(crate) struct EffectiveTraits { pub(crate) resolved_trait_schemas: Vec, /// RFC 7396-merged `x-gts-traits` values across the chain (pre-defaults). pub(crate) merged_traits: Value, + /// The type these traits are validated for: `/$id` in the trait schema + /// names it (spec v0.14 §9.6). The composed schema has no `$id` of its own. + pub(crate) selected_type: Option, } impl EffectiveTraits { + /// These traits, validated on behalf of the leaf `type_id`. + pub(crate) fn for_type(mut self, type_id: &str) -> Self { + self.selected_type = Some(type_id.to_owned()); + self + } + /// `true` when the chain contributed at least one `x-gts-traits-schema`. fn has_schema(&self) -> bool { !self.resolved_trait_schemas.is_empty() @@ -126,7 +135,13 @@ impl EffectiveTraits { return Ok(()); } - validate_trait_values(&self.schema, &self.values, check_unresolved, entity_exists) + validate_trait_values( + &self.schema, + &self.values, + check_unresolved, + self.selected_type.as_deref(), + entity_exists, + ) } } @@ -235,7 +250,9 @@ pub fn validate_traits_chain(chain_schemas: &[(String, Value)]) -> Result<(), Ve let mut trait_schemas = Vec::new(); let mut merged = serde_json::Map::new(); for (_id, content) in chain_schemas { - collect_trait_schema_from_value(content, &mut trait_schemas); + let mut level = Vec::new(); + collect_trait_schema_from_value(content, &mut level); + trait_schemas.extend(level.into_iter().cloned()); collect_traits_from_value(content, &mut merged); } // Dialect comes from the leaf document's `$schema`, mirroring the store path. @@ -365,6 +382,7 @@ pub(crate) fn build_effective_traits( values, resolved_trait_schemas: resolved_trait_schemas.to_vec(), merged_traits: merged_traits.clone(), + selected_type: None, } } @@ -373,12 +391,14 @@ fn validate_trait_values( effective_traits_schema: &Value, effective_traits: &Value, check_unresolved: bool, + selected_type: Option<&str>, entity_exists: Option, ) -> Result<(), Vec> { let mut errors = match validate_traits_against_schema( effective_traits_schema, effective_traits, check_unresolved, + selected_type, entity_exists, ) { Ok(()) => Vec::new(), @@ -446,14 +466,29 @@ fn effective_schema_is_false_recursive(schema: &Value, depth: usize) -> bool { /// /// Only pointers that actually resolve against `root` are inlined; anything /// else (notably `gts://` refs and the synthetic `#/$defs/GtsInstanceId` family -/// that schema resolution special-cases) is left untouched. Recursion is -/// bounded by [`MAX_RECURSION_DEPTH`]. +/// that schema resolution special-cases) is left untouched. An embedded +/// resource with an `$id` of its own is copied as is: its references resolve +/// from it, which `$ref` resolution does once the fragment is a document. A +/// pointer into one resolves the target's own references from that resource. +/// Recursion is bounded by [`MAX_RECURSION_DEPTH`]. +/// +/// `fragment` must lie in `root`: embedded resources are known by identity. pub(crate) fn inline_local_pointers(fragment: &Value, root: &Value) -> Value { - inline_local_pointers_recursive(fragment, root, 0) + let embedded = crate::schema_modifiers::embedded_resources(root); + let is_resource = |node: &Value| { + node.as_object() + .is_some_and(|map| embedded.contains(&std::ptr::from_ref(map))) + }; + inline_local_pointers_recursive(fragment, root, &is_resource, 0) } -fn inline_local_pointers_recursive(value: &Value, root: &Value, depth: usize) -> Value { - if depth >= MAX_RECURSION_DEPTH { +fn inline_local_pointers_recursive( + value: &Value, + root: &Value, + is_resource: &dyn Fn(&Value) -> bool, + depth: usize, +) -> Value { + if depth >= MAX_RECURSION_DEPTH || is_resource(value) { return value.clone(); } match value { @@ -462,9 +497,12 @@ fn inline_local_pointers_recursive(value: &Value, root: &Value, depth: usize) -> && 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); + // Resolve the target from the innermost resource holding it, + // then overlay any sibling keywords (JSON Schema + // `$ref`-with-siblings) resolved from the same root as the ref. + let target_root = innermost_resource(root, ptr, is_resource); + let mut resolved = + inline_local_pointers_recursive(target, target_root, is_resource, depth + 1); if map.len() > 1 && let Value::Object(resolved_map) = &mut resolved { @@ -472,7 +510,7 @@ fn inline_local_pointers_recursive(value: &Value, root: &Value, depth: usize) -> if k != "$ref" { resolved_map.insert( k.clone(), - inline_local_pointers_recursive(v, root, depth + 1), + inline_local_pointers_recursive(v, root, is_resource, depth + 1), ); } } @@ -483,29 +521,56 @@ fn inline_local_pointers_recursive(value: &Value, root: &Value, depth: usize) -> for (k, v) in map { out.insert( k.clone(), - inline_local_pointers_recursive(v, root, depth + 1), + inline_local_pointers_recursive(v, root, is_resource, depth + 1), ); } Value::Object(out) } Value::Array(arr) => Value::Array( arr.iter() - .map(|v| inline_local_pointers_recursive(v, root, depth + 1)) + .map(|v| inline_local_pointers_recursive(v, root, is_resource, depth + 1)) .collect(), ), _ => value.clone(), } } +/// The innermost resource on the way from `root` along the JSON Pointer +/// `/pointer`, excluding its target: `root` when none is crossed. +fn innermost_resource<'v>( + root: &'v Value, + pointer: &str, + is_resource: &dyn Fn(&Value) -> bool, +) -> &'v Value { + let mut node = root; + let mut innermost = root; + for token in pointer.split('/') { + if is_resource(node) { + innermost = node; + } + let token = token.replace("~1", "/").replace("~0", "~"); + let next = match node { + Value::Object(map) => map.get(&token), + Value::Array(items) => token.parse::().ok().and_then(|i| items.get(i)), + _ => None, + }; + let Some(next) = next else { + break; + }; + node = next; + } + innermost +} + /// Recursively search a schema value for `x-gts-traits-schema` entries. /// /// Handles both top-level and `allOf`-nested occurrences. /// Recursion is bounded by [`MAX_RECURSION_DEPTH`] to prevent stack overflow. -pub(crate) fn collect_trait_schema_from_value(value: &Value, out: &mut Vec) { +pub(crate) fn collect_trait_schema_from_value<'v>(value: &'v Value, out: &mut Vec<&'v Value>) { collect_trait_schema_recursive(value, out, 0); } -fn collect_trait_schema_recursive(value: &Value, out: &mut Vec, depth: usize) { +fn collect_trait_schema_recursive<'v>(value: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { if depth >= MAX_RECURSION_DEPTH { return; } @@ -515,7 +580,7 @@ fn collect_trait_schema_recursive(value: &Value, out: &mut Vec, depth: us }; if let Some(ts) = obj.get(X_GTS_TRAITS_SCHEMA) { - out.push(ts.clone()); + out.push(ts); } // Also check inside allOf items (e.g. a derived schema that is an allOf overlay) @@ -855,11 +920,13 @@ fn strip_required_recursive(schema: &mut Value, depth: usize) { /// Validate the effective traits object against the effective trait schema. /// /// Validates standard and GTS constraints with dialect-aware applicability. -/// `entity_exists` enables store-aware reference checks. +/// `selected_type` is what `/$id` names; `entity_exists` enables store-aware +/// reference checks. fn validate_traits_against_schema( trait_schema: &Value, effective_traits: &Value, check_unresolved: bool, + selected_type: Option<&str>, entity_exists: Option, ) -> Result<(), Vec> { let mut errors = Vec::new(); @@ -878,7 +945,12 @@ fn validate_traits_against_schema( &stripped }; - match crate::json_schema::gts_validator_for(validation_schema, entity_exists) { + match crate::json_schema::gts_validator_for_type( + validation_schema, + selected_type, + &[], + entity_exists, + ) { Ok(validator) => { let diagnosis = crate::json_schema::diagnose(&validator, validation_schema, effective_traits); @@ -2416,6 +2488,7 @@ mod tests { &schema, &values, false, + None, Some(std::sync::Arc::new(|_| false)), ); let errors = res.expect_err("a dangling verifiable reference must fail"); @@ -2435,7 +2508,7 @@ mod tests { }); let values = json!({"topicRef": "gts.x.a.b.topic.v1~x.c._.orders.v1"}); - super::validate_trait_values(&schema, &values, false, super::REFS_UNVERIFIABLE) + super::validate_trait_values(&schema, &values, false, None, super::REFS_UNVERIFIABLE) .expect("an unverifiable reference must be tolerated"); } @@ -2460,6 +2533,7 @@ mod tests { &schema, &values, false, + None, Some(std::sync::Arc::new(|_| false)), ) .unwrap_or_else(|errors| panic!("{label}: {errors:?}")); @@ -2481,6 +2555,7 @@ mod tests { &schema, &values, false, + None, Some(std::sync::Arc::new(|_| false)), ) .expect_err("no branch tolerates the dangling reference"); @@ -2497,7 +2572,8 @@ mod tests { // 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, super::REFS_UNVERIFIABLE); + let res = + super::validate_trait_values(&schema, &values, false, None, super::REFS_UNVERIFIABLE); assert!( res.is_err(), "x-gts-ref violation should be reported: {res:?}" diff --git a/gts/src/store.rs b/gts/src/store.rs index 89e2611..99d7299 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use thiserror::Error; use crate::entities::GtsEntity; @@ -11,6 +11,7 @@ use crate::schema_evolution::{ check_forward_diagnostics, classify_object_levels, }; use crate::schema_resolver::SchemaProvider; +use crate::x_gts_ref::GtsRefValidation; #[derive(Debug, Error)] pub enum StoreError { @@ -137,7 +138,8 @@ impl SchemaComparison { } } -/// Fully-resolved, self-contained view of a GTS type. +/// Resolved view of a GTS type: self-contained unless a `$ref` cycle makes +/// inlining impossible, in which case `schema` is the body as authored. /// /// 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 @@ -154,7 +156,8 @@ pub struct ResolvedType { pub is_abstract: bool, /// `true` when the type declares `x-gts-final: true` — it cannot be extended. pub is_final: bool, - /// Type body with all `#/` and `gts://` `$ref`s inlined. + /// Type body with all `#/` and `gts://` `$ref`s inlined, or the body as + /// authored when a `$ref` cycle makes inlining impossible. pub schema: Value, /// Chain-merged (RFC 7396) and default-materialized trait values. pub effective_traits: Value, @@ -174,6 +177,14 @@ pub(crate) enum Registration { pub struct GtsStore { by_id: HashMap, reader: Option>, + /// Ids whose validation is already on the stack. Reference cycles resolve + /// to "valid" here so the outer validation is the one that decides. + validating: HashSet, + /// What [`Self::entity_is_valid`] has decided during the running + /// validation, so an entity reached along many paths is validated once. + verdicts: HashMap, + /// The ids `verdicts` holds as valid, in the order they were decided. + judged_valid: Vec, } impl Default for GtsStore { @@ -204,6 +215,9 @@ impl GtsStore { GtsStore { by_id: HashMap::new(), reader: None, + validating: HashSet::new(), + verdicts: HashMap::new(), + judged_valid: Vec::new(), } } @@ -214,6 +228,9 @@ impl GtsStore { let mut store = GtsStore { by_id: HashMap::new(), reader: Some(reader), + validating: HashSet::new(), + verdicts: HashMap::new(), + judged_valid: Vec::new(), }; store.populate_from_reader(); tracing::info!("Populated GtsStore with {} entities", store.by_id.len()); @@ -303,14 +320,48 @@ impl GtsStore { } } - /// Registers a schema in the store. + /// The GTS Type Identifier a canonical JSON GTS Type Schema declares. /// - /// Ids are immutable here too: see [`Self::register`]. + /// Canonical means a top-level `$schema` and a top-level `$id` of the form + /// `gts://` (README §2.4). Whether the dialect is supported is a + /// validation question, not an identity one, so it is not checked here. + /// + /// # Errors + /// Why `schema` is not a canonical GTS Type Schema. + pub(crate) fn declared_type_id(schema: &Value) -> Result { + let Some(schema) = schema.as_object() else { + return Err("a GTS Type Schema must be a JSON object".to_owned()); + }; + if !schema.get("$schema").is_some_and(Value::is_string) { + return Err("a GTS Type Schema must declare a top-level '$schema'".to_owned()); + } + let declared = schema.get("$id").and_then(Value::as_str); + declared + .and_then(|id| id.strip_prefix(crate::gts::GTS_ID_URI_PREFIX)) + .filter(|id| GtsId::try_new(id).is_ok_and(|id| id.is_type())) + .map(str::to_owned) + .ok_or_else(|| { + format!( + "a GTS Type Schema must declare a top-level '$id' of the form \ + '{}' naming a GTS Type Identifier, got {}", + crate::gts::GTS_ID_URI_PREFIX, + declared.map_or_else(|| "none".to_owned(), |id| format!("'{id}'")) + ) + }) + } + + /// Registers a schema in the store under an explicitly supplied id. + /// + /// The document must still be a canonical GTS Type Schema whose `$id` + /// names `type_id`: a separately supplied id never stands in for the + /// embedded one (README §2.4). Ids are immutable here too: see + /// [`Self::register`]. /// /// # Errors /// Returns `StoreError::InvalidTypeId` if `type_id` is not a valid GTS type - /// id, or `StoreError::ImmutableConflict` if the id is already bound to - /// different content. + /// id, `StoreError::InvalidEntity` if `schema` is not a canonical GTS Type + /// Schema for `type_id`, or `StoreError::ImmutableConflict` if the id is + /// already bound to different content. pub fn register_schema(&mut self, type_id: &str, schema: &Value) -> Result<(), StoreError> { let gts_id = GtsId::try_new(type_id).map_err(StoreError::InvalidTypeId)?; if !gts_id.is_type() { @@ -319,7 +370,13 @@ impl GtsStore { "GTS type IDs must end with '~'", ))); } - let mut entity = GtsEntity::new( + let declared = Self::declared_type_id(schema).map_err(StoreError::InvalidEntity)?; + if declared != type_id { + return Err(StoreError::InvalidEntity(format!( + "'$id' declares '{declared}', but the schema is registered as '{type_id}'" + ))); + } + let entity = GtsEntity::new( None, None, schema, @@ -330,8 +387,6 @@ impl GtsStore { None, None, ); - // The API declares schema intent even without `$schema`. - entity.is_schema = true; self.register(entity) } @@ -420,6 +475,43 @@ impl GtsStore { crate::schema_resolver::SchemaResolver::new(self).resolve(schema) } + /// The registered documents `schema` reaches through `gts://` references, + /// transitively, keyed by the `gts://` URI they are referenced by. + /// + /// A validator compiled with them follows every `$ref` itself, under the + /// rules of the dialect it appears in: cycles, embedded resources and + /// `$ref` siblings all behave as JSON Schema defines. `schema` itself is + /// found by its own `$id`. + /// + /// # Errors + /// [`StoreError::InvalidRef`] for a malformed reference, or + /// [`StoreError::UnresolvedRefs`] for one no registered schema answers. + fn validation_resources(&self, schema: &Value) -> Result, StoreError> { + let references_of = |document: &Value| { + crate::schema_refs::extract_gts_refs(document) + .map_err(|e| StoreError::InvalidRef(e.to_string())) + }; + let own = schema.get("$id").and_then(Value::as_str); + let mut pending = references_of(schema)?; + let mut seen = std::collections::BTreeSet::new(); + let mut resources = Vec::new(); + while let Some(id) = pending.pop_first() { + if !seen.insert(id.clone()) { + continue; + } + let uri = format!("{}{id}", crate::gts::GTS_ID_URI_PREFIX); + if own == Some(uri.as_str()) { + continue; + } + let Some(document) = self.schema_content(&id) else { + return Err(StoreError::UnresolvedRefs(vec![uri])); + }; + pending.extend(references_of(document)?); + resources.push((uri, document)); + } + Ok(resources) + } + /// 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( @@ -637,13 +729,13 @@ impl GtsStore { // 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(); + let mut level_trait_schemas = 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)); + trait_schemas.push(crate::schema_traits::inline_local_pointers(ts, &content)); } let mut level_traits = serde_json::Map::new(); @@ -671,7 +763,8 @@ impl GtsStore { &resolved_trait_schemas, &Value::Object(merged_traits), dialect.as_deref(), - )) + ) + .for_type(type_id)) } /// Fully validate a registered type schema and return its resolved @@ -707,26 +800,106 @@ impl GtsStore { /// dependency is missing from the store; `StoreError::SchemaNotFound` if the /// type is not registered. pub fn validate_schema(&mut self, type_id: &str) -> Result { + self.validate_schema_with(type_id, GtsRefValidation::default()) + } + + /// [`Self::validate_schema`] under an explicit `x-gts-ref` mode. + /// + /// # Errors + /// See [`Self::validate_schema`]. + pub fn validate_schema_with( + &mut self, + type_id: &str, + refs: GtsRefValidation, + ) -> Result { + self.begin_validation(); + self.check_schema(type_id, refs) + } + + /// Starts a top-level validation. Verdicts are shared only within one: + /// registrations between calls can change what is valid. Nothing is on the + /// stack here, so clearing `validating` only drops what a caught panic left. + fn begin_validation(&mut self) { + self.validating.clear(); + self.verdicts.clear(); + self.judged_valid.clear(); + } + + /// [`Self::validate_schema_with`] as part of the running validation. + fn check_schema( + &mut self, + type_id: &str, + refs: GtsRefValidation, + ) -> Result { + let resolved = self.validate_schema_locally(type_id, refs)?; + let mut validated = HashSet::from([type_id.to_owned()]); + self.validate_related_types(type_id, refs, &mut validated)?; + Ok(resolved) + } + + /// Validates the types `type_id` derives from and `$ref`s, transitively. + /// + /// A type is only as valid as what it builds on, so an invalid ancestor or + /// reference target invalidates it too (spec v0.14 §12). `validated` both + /// memoizes and breaks reference cycles. + fn validate_related_types( + &mut self, + type_id: &str, + refs: GtsRefValidation, + validated: &mut HashSet, + ) -> Result<(), StoreError> { + for related in self.related_type_ids(type_id)? { + if !validated.insert(related.clone()) { + continue; + } + self.validate_schema_locally(&related, refs).map_err(|e| { + StoreError::ValidationError(format!( + "'{type_id}' depends on GTS type '{related}', which is invalid: {e}" + )) + })?; + self.validate_related_types(&related, refs, validated)?; + } + Ok(()) + } + + /// The immediate base type and every `gts://` `$ref` target of `type_id`. + fn related_type_ids(&mut self, type_id: &str) -> Result, StoreError> { + let content = self.get_schema_content(type_id)?; + let mut related: Vec = GtsId::try_new(type_id) + .ok() + .and_then(|id| id.get_type_id()) + .into_iter() + .collect(); + related.extend( + crate::schema_refs::extract_gts_refs(&content) + .map_err(|e| StoreError::InvalidRef(e.to_string()))?, + ); + related.retain(|id| id != type_id); + Ok(related) + } + + /// Validates `type_id` on its own, without following its dependencies. + fn validate_schema_locally( + &mut self, + type_id: &str, + refs: GtsRefValidation, + ) -> Result { let content = self.get_schema_content(type_id)?; if !content.is_object() { return Err(StoreError::InvalidEntity(format!( "Schema '{type_id}' content must be a dictionary" ))); } - if content - .get("$schema") - .is_some_and(|value| value.as_str().is_none_or(str::is_empty)) - { - return Err(StoreError::ValidationError(format!( - "JSON Schema validation failed for '{type_id}': '$schema' must be a non-empty string" - ))); - } + // First, so nothing below reads any part of the type under a dialect + // its author did not choose. + self.check_dialect(type_id, &content)?; // Validate $ref URIs (must be local #... or gts:// type ids) Self::validate_ref_uris(&content)?; // Validate x-gts-ref values (must be valid GTS ids) Self::validate_schema_x_gts_refs(&content)?; + self.check_constraint_targets(&content, refs)?; // Validate GTS keywords crate::schema_modifiers::validate_gts_keywords(&content) @@ -735,27 +908,42 @@ impl GtsStore { // Validate schema derivation chain and base type compatibility self.validate_schema_chain(type_id)?; - // Resolve schema references - let resolved_schema = self - .resolve_schema_refs(&content) - .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; + // Resolve schema references. JSON Schema allows recursion, so a cyclic + // `$ref` graph makes the document unresolvable rather than invalid: + // inlining is skipped here and whatever must materialize the document + // (a trait-schema chain) reports the cycle at that point. + let resolved_schema = match self.resolve_schema_refs(&content) { + Ok(resolved) => Some(resolved), + Err(StoreError::CircularRef) => None, + Err(e) => { + return Err(StoreError::ValidationError(format!( + "Schema '{type_id}' has {e}" + ))); + } + }; - // Meta-validate the fully-resolved schema. Registration only checks - // `$ref`/`x-gts-ref` structure (see `validate_schema_refs`); now that - // every dependency is inlined we can compile the resolved body and catch - // malformed schema structure outside the refs. - // JSON Schema permits the already-validated extension keywords. - let mut schema_for_validation = resolved_schema.clone(); - if let Value::Object(ref mut map) = schema_for_validation { - map.remove("$id"); - map.remove("$schema"); - } - crate::json_schema::validator_for(&schema_for_validation).map_err(|e| { + // Syntax belongs to the document as authored, so it is checked against + // the declared meta-schema without dereferencing anything — a `$ref` + // cycle must not buy a schema an exemption from being well-formed. + jsonschema::meta::validate(&content).map_err(|e| { StoreError::ValidationError(format!( "JSON Schema validation failed for '{type_id}': {e}" )) })?; + // Compile the body exactly as instance validation builds it, so an + // accepted type is one its instances can be validated against: with + // every document it reaches, a `$ref` cycle included. + let resources = self + .validation_resources(&content) + .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; + crate::json_schema::gts_validator_for_type(&content, Some(type_id), &resources, None) + .map_err(|e| { + StoreError::ValidationError(format!( + "JSON Schema validation failed for '{type_id}': {e}" + )) + })?; + // Trait values are always validated against the effective trait-schema // (type/enum/`x-gts-ref` conformance), even for abstract types. Only the // required-trait *completeness* check is gated: an abstract type may @@ -763,56 +951,169 @@ impl GtsStore { // validated with `check_unresolved = false`. let is_abstract = Self::content_is_abstract(&content); let traits = self.effective_traits(type_id)?; - // Prefetch because the validation predicate cannot borrow the store. - let mut unsatisfied = std::collections::HashSet::new(); - for reference in crate::x_gts_ref::candidate_reference_values(&traits.values) { - if !self.reference_is_satisfied(&reference) { - unsatisfied.insert(reference); - } - } - let unsatisfied = std::sync::Arc::new(unsatisfied); + let satisfied = self.unsatisfied_references(&traits.values, refs); traits - .validate( - !is_abstract, - Some(std::sync::Arc::new(move |reference: &str| { - !unsatisfied.contains(reference) - })), - ) + .validate(!is_abstract, Some(satisfied)) .map_err(|errors| Self::wrap_trait_error(type_id, &errors))?; Ok(ResolvedType { id: crate::GtsTypeId::try_new(type_id).map_err(StoreError::InvalidTypeId)?, is_abstract, is_final: Self::content_is_final(&content), - schema: resolved_schema, + schema: resolved_schema.unwrap_or(content), effective_traits: traits.values, effective_traits_schema: traits.schema, }) } - /// Checks references the store can reach; accepts unverifiable external - /// registries. + /// Checks that `type_id` declares a supported dialect, the same one as the + /// root of its `$id` chain, that none of its subschemas switches to another + /// one, and that no `$ref` in it crosses dialects (README §11.0). /// - /// Reachability is decided through [`Self::get`] for both the owning type - /// and the reference itself, so a [`GtsReader`] that serves the type must - /// serve the instance too. Gating the type on `by_id` alone would wave a - /// dangling reference through whenever the type is only lazily readable. - /// Both lookups warm the reader cache - that is what the `&mut self` is for. - fn reference_is_satisfied(&mut self, reference: &str) -> bool { - let Ok(gid) = GtsId::try_new(reference) else { - return true; + /// Intermediate chain members and `gts://` targets get the same check when + /// [`Self::validate_related_types`] validates them, so the whole hierarchy + /// and reference graph end up on one dialect. + fn check_dialect(&mut self, type_id: &str, content: &Value) -> Result<(), StoreError> { + let fail = |reason: String| { + StoreError::ValidationError(format!( + "JSON Schema dialect check failed for '{type_id}': {reason}" + )) }; + let dialect = crate::schema_dialect::document_dialect(content).map_err(fail)?; + + let root_id = GtsId::try_new(type_id) + .ok() + .and_then(|id| id.chain_ids().into_iter().next()) + .filter(|root_id| root_id != type_id); + if let Some(root_id) = root_id + && let Some(root) = self.get(&root_id) + { + let root_dialect = jsonschema::Draft::default().detect(&root.content); + if root_dialect != dialect { + return Err(fail(format!( + "it declares {} but its root type '{root_id}' selects {}; \ + a derivation hierarchy has a single dialect", + crate::schema_dialect::dialect_name(dialect), + crate::schema_dialect::dialect_name(root_dialect) + ))); + } + } - let Some(owning_type) = gid.get_type_id() else { + crate::schema_dialect::check_subschemas(content, dialect).map_err(fail)?; + crate::schema_dialect::check_references(content, self).map_err(fail) + } + + /// Whether `reference` satisfies `refs` as an `x-gts-ref` target. + /// + /// Non-identifiers are left to the pattern check, which reports them. + fn reference_is_satisfied(&mut self, reference: &str, refs: GtsRefValidation) -> bool { + if !refs.checks_registry() || GtsId::try_new(reference).is_err() { return true; - }; + } + if self.get(reference).is_none() { + return false; + } + !refs.checks_validity() || self.entity_is_valid(reference) + } - if self.get(&owning_type).is_none() { + /// Whether a registered entity validates. Cycles count as valid: the + /// validation already on the stack is the one that reports the problem. + /// + /// Decided once per validation. A verdict reached while a cycle was + /// assumed valid is withdrawn if that assumption fails. + fn entity_is_valid(&mut self, entity_id: &str) -> bool { + if let Some(&valid) = self.verdicts.get(entity_id) { + return valid; + } + if !self.validating.insert(entity_id.to_owned()) { return true; } + let judged_before = self.judged_valid.len(); + let valid = match GtsId::try_new(entity_id) { + Ok(id) if id.is_type() => self + .check_schema(entity_id, GtsRefValidation::AnyValid) + .is_ok(), + Ok(_) => self + .check_instance(entity_id, GtsRefValidation::AnyValid) + .is_ok(), + Err(_) => true, + }; + self.validating.remove(entity_id); + if valid { + self.judged_valid.push(entity_id.to_owned()); + } else { + // Anything found valid meanwhile may have assumed this entity valid + // to break a cycle, so those verdicts are no longer safe to keep. + for withdrawn in self.judged_valid.drain(judged_before..) { + self.verdicts.remove(&withdrawn); + } + } + self.verdicts.insert(entity_id.to_owned(), valid); + valid + } + + /// The candidate values in `document` that `refs` rejects. + /// + /// Precomputed because the `x-gts-ref` keyword cannot borrow the store. + fn unsatisfied_references( + &mut self, + document: &Value, + refs: GtsRefValidation, + ) -> crate::x_gts_ref::ReferenceExists { + let mut unsatisfied = HashSet::new(); + for reference in crate::x_gts_ref::candidate_reference_values(document) { + if !self.reference_is_satisfied(&reference, refs) { + unsatisfied.insert(reference); + } + } + std::sync::Arc::new(move |reference: &str| !unsatisfied.contains(reference)) + } + + /// Checks that every `x-gts-ref` constraint target in `content` resolves. + /// + /// A non-wildcard pattern names one type; a wildcard is satisfied by any + /// registered match, so the registry is scanned for one. + fn check_constraint_targets( + &mut self, + content: &Value, + refs: GtsRefValidation, + ) -> Result<(), StoreError> { + if !refs.checks_registry() { + return Ok(()); + } + for (location, pattern) in crate::x_gts_ref::declared_patterns(content) { + let spelling = pattern.pattern().to_owned(); + let satisfied = if spelling.contains('*') { + self.matching_ids(&pattern) + .into_iter() + .any(|id| !refs.checks_validity() || self.entity_is_valid(&id)) + } else { + self.get(&spelling).is_some() + && (!refs.checks_validity() || self.entity_is_valid(&spelling)) + }; + if !satisfied { + let requirement = if refs.checks_validity() { + "no registered GTS type satisfies it and validates" + } else { + "no registered GTS type satisfies it" + }; + return Err(StoreError::ValidationError(format!( + "x-gts-ref validation failed: {location} constrains values to \ + '{spelling}', but {requirement}" + ))); + } + } + Ok(()) + } - self.get(reference).is_some() + /// Registered ids matching `pattern`. + fn matching_ids(&self, pattern: &GtsIdPattern) -> Vec { + self.by_id + .keys() + .filter(|id| GtsId::try_new(id).is_ok_and(|parsed| parsed.matches_pattern(pattern))) + .cloned() + .collect() } /// Validate a caller-supplied instance payload against `type_id`'s schema. @@ -826,6 +1127,30 @@ impl GtsStore { /// 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> { + self.validate_payload_with(type_id, payload, GtsRefValidation::default()) + } + + /// [`Self::validate_payload`] under an explicit `x-gts-ref` mode. + /// + /// # Errors + /// See [`Self::validate_payload`]. + pub fn validate_payload_with( + &mut self, + type_id: &str, + payload: &Value, + refs: GtsRefValidation, + ) -> Result<(), StoreError> { + self.begin_validation(); + self.check_payload(type_id, payload, refs) + } + + /// [`Self::validate_payload_with`] as part of the running validation. + fn check_payload( + &mut self, + type_id: &str, + payload: &Value, + refs: GtsRefValidation, + ) -> Result<(), StoreError> { let content = self.get_schema_content(type_id)?; // Abstract types cannot have direct instances (OP#6). @@ -835,20 +1160,36 @@ impl GtsStore { ))); } - // 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(&content) - .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; - - // External `$ref`s are already resolved; this also enforces `x-gts-ref`. - let validator = - crate::json_schema::gts_validator_for(&resolved_schema, None).map_err(|e| { - StoreError::ValidationError(format!("Invalid schema for '{type_id}': {e}")) + // An instance is no more valid than the type it claims. + if !self.validating.contains(type_id) { + self.check_schema(type_id, refs).map_err(|e| { + StoreError::ValidationError(format!("type '{type_id}' is invalid: {e}")) })?; + } - let diagnosis = crate::json_schema::diagnose(&validator, &resolved_schema, payload); + // Payload validation needs only the type body — traits are + // schema-level metadata (§9.7) and never appear in instances, so the + // effective-traits build is deliberately skipped here. The validator + // follows the body's `$ref`s itself; this also enforces `x-gts-ref`. + let satisfied = self.unsatisfied_references(payload, refs); + let resources = self + .validation_resources(&content) + .map_err(|e| StoreError::ValidationError(format!("Schema '{type_id}' has {e}")))?; + let validator = crate::json_schema::gts_validator_for_type( + &content, + Some(type_id), + &resources, + Some(satisfied), + ) + .map_err(|e| StoreError::ValidationError(format!("Invalid schema for '{type_id}': {e}")))?; + + if validator.is_valid(payload) { + return Ok(()); + } + // Inlined, the body shows whether a rejection is affordable to explain. + let resolved = self.resolve_schema_refs(&content).ok(); + let diagnosis = + crate::json_schema::diagnose_resolved(&validator, &content, resolved.as_ref(), payload); let mut errors = diagnosis.standard; errors.extend(diagnosis.unexplained); if !errors.is_empty() { @@ -867,6 +1208,28 @@ impl GtsStore { /// # Errors /// Returns `StoreError` if validation fails. pub fn validate_instance(&mut self, instance_id: &str) -> Result<(), StoreError> { + self.validate_instance_with(instance_id, GtsRefValidation::default()) + } + + /// [`Self::validate_instance`] under an explicit `x-gts-ref` mode. + /// + /// # Errors + /// See [`Self::validate_instance`]. + pub fn validate_instance_with( + &mut self, + instance_id: &str, + refs: GtsRefValidation, + ) -> Result<(), StoreError> { + self.begin_validation(); + self.check_instance(instance_id, refs) + } + + /// [`Self::validate_instance_with`] as part of the running validation. + fn check_instance( + &mut self, + instance_id: &str, + refs: GtsRefValidation, + ) -> Result<(), StoreError> { let obj = self.get_instance_entity(instance_id)?; let type_id = obj.type_id.as_ref().ok_or_else(|| { @@ -881,7 +1244,7 @@ impl GtsStore { // A registered instance is just a stored payload; validation is identical // to validating a caller-supplied payload against its declared type. - self.validate_payload(type_id, &obj.content) + self.check_payload(type_id, &obj.content, refs) } /// Casts an entity from one schema to another. diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index 052f484..aa4ad3a 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -151,7 +151,7 @@ fn test_gts_store_items_iterator() { // Add schemas which are easier to register for i in 0..3 { let schema_content = json!({ - "$id": format!("gts.vendor.package.namespace.type.v{i}.0~"), + "$id": format!("gts://gts.vendor.package.namespace.type.v{i}.0~"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }); @@ -227,7 +227,7 @@ fn test_gts_store_query_wildcard() { // Add multiple schemas for i in 0..3 { let schema_content = json!({ - "$id": format!("gts.vendor.package.namespace.type.v{i}.0~"), + "$id": format!("gts://gts.vendor.package.namespace.type.v{i}.0~"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }); @@ -252,7 +252,7 @@ fn test_gts_store_query_with_limit() { // Add 5 schemas for i in 0..5 { let schema_content = json!({ - "$id": format!("gts.vendor.package.namespace.type.v{i}.0~"), + "$id": format!("gts://gts.vendor.package.namespace.type.v{i}.0~"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }); @@ -839,6 +839,7 @@ fn test_gts_store_is_compatible_rejects_non_schema_entity() { .register_schema( new_id, &json!({ + "$id": format!("gts://{new_id}"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }), @@ -963,7 +964,7 @@ fn test_gts_store_query_with_filters() { for i in 0..5 { let schema = json!({ - "$id": format!("gts.vendor.package.namespace.type{i}.v1.0~"), + "$id": format!("gts://gts.vendor.package.namespace.type{i}.v1.0~"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }); @@ -986,7 +987,7 @@ fn test_gts_store_register_multiple_schemas() { for i in 0..10 { let schema = json!({ - "$id": format!("gts.vendor.package.namespace.type.v1.{i}~"), + "$id": format!("gts://gts.vendor.package.namespace.type.v1.{i}~"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }); @@ -1529,7 +1530,7 @@ fn test_gts_store_items_iterator_multiple() { for i in 0..5 { let schema = json!({ - "$id": format!("gts.vendor.package.namespace.type{i}.v1.0~"), + "$id": format!("gts://gts.vendor.package.namespace.type{i}.v1.0~"), "$schema": "http://json-schema.org/draft-07/schema#", "type": "object" }); @@ -1969,16 +1970,38 @@ fn test_gts_store_build_schema_graph_single_schema() { } #[test] -fn test_gts_store_register_schema_without_id() { +fn test_gts_store_register_schema_requires_the_embedded_identity() { let mut store = GtsStore::new(); + let type_id = "gts.vendor.package.namespace.type.v1.0~"; - let schema = json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object" - }); + // A separately supplied id never stands in for `$id` (README §2.4). + for (schema, expected) in [ + (json!({"$schema": DRAFT7, "type": "object"}), "'$id'"), + ( + json!({"$schema": DRAFT7, "$id": "gts://gts.vendor.package.namespace.other.v1.0~"}), + "registered as", + ), + ( + json!({"$id": format!("gts://{type_id}"), "type": "object"}), + "'$schema'", + ), + ] { + let err = store + .register_schema(type_id, &schema) + .expect_err("a non-canonical schema must be refused"); + assert!( + matches!(&err, StoreError::InvalidEntity(msg) if msg.contains(expected)), + "{schema}: {err}" + ); + assert!(store.get(type_id).is_none(), "nothing may be stored"); + } - let result = store.register_schema("gts.vendor.package.namespace.type.v1.0~", &schema); - assert!(result.is_ok()); + store + .register_schema( + type_id, + &json!({"$schema": DRAFT7, "$id": format!("gts://{type_id}")}), + ) + .expect("a canonical schema registers"); } #[test] @@ -2854,23 +2877,18 @@ fn test_validate_schema_entity_not_schema() { } #[test] -fn test_validate_schema_content_not_object() { +fn test_register_schema_refuses_non_object_content() { let mut store = GtsStore::new(); - // Create schema with non-object content (an array) - let schema_content = json!(["not", "an", "object"]); - - store - .register_schema("gts.vendor.package.namespace.type.v1.0~", &schema_content) - .expect("test"); - - let result = store.validate_schema_refs("gts.vendor.package.namespace.type.v1.0~"); - assert!(result.is_err()); + let result = store.register_schema( + "gts.vendor.package.namespace.type.v1.0~", + &json!(["not", "an", "object"]), + ); match result { Err(StoreError::InvalidEntity(msg)) => { - assert!(msg.contains("content must be a dictionary"), "{msg}"); + assert!(msg.contains("must be a JSON object"), "{msg}"); } - _ => panic!("Expected InvalidEntity error"), + other => panic!("Expected InvalidEntity error, got {other:?}"), } } @@ -2919,7 +2937,7 @@ fn test_validate_instance_schema_compilation_error() { assert!(result.is_err()); match result { Err(StoreError::ValidationError(msg)) => { - assert!(msg.contains("Invalid schema"), "Actual: {msg}"); + assert!(msg.contains("is invalid"), "Actual: {msg}"); } Err(e) => panic!("Expected ValidationError for invalid schema, got: {e:?}"), _ => panic!("Expected an error"), @@ -4095,6 +4113,7 @@ fn test_store_query_with_very_large_limit() { .register_schema( "gts.test.package.namespace.foo.v1~", &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", "$id": "gts://gts.test.package.namespace.foo.v1~", "type": "object" }), @@ -4115,6 +4134,7 @@ fn test_store_register_schema_validates_type_id() { let result = store.register_schema( type_id, &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", "$id": format!("gts://{type_id}"), "type": "object" }), @@ -4126,6 +4146,7 @@ fn test_store_register_schema_validates_type_id() { let result = store.register_schema( bad_id, &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", "$id": format!("gts://{bad_id}"), "type": "object" }), @@ -4377,6 +4398,7 @@ fn test_effective_traits_walks_id_chain() { .register_schema( "gts.x.cti.tr.base.v1~", &json!({ + "$id": "gts://gts.x.cti.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": { "type": "object", "properties": { @@ -4391,6 +4413,7 @@ fn test_effective_traits_walks_id_chain() { .register_schema( "gts.x.cti.tr.base.v1~x.cti._.leaf.v1~", &json!({ + "$id": "gts://gts.x.cti.tr.base.v1~x.cti._.leaf.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits": {"tier": "premium"} @@ -4468,6 +4491,7 @@ fn test_resolved_type_single_level_full_artifacts() { .register_schema( "gts.x.rs.tr.base.v1~", &json!({ + "$id": "gts://gts.x.rs.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4487,6 +4511,7 @@ fn test_resolved_type_single_level_full_artifacts() { false, false, json!({ + "$id": "gts://gts.x.rs.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4517,6 +4542,7 @@ fn test_resolved_type_single_level_default_materialized() { .register_schema( "gts.x.ep.tr.base.v1~", &json!({ + "$id": "gts://gts.x.ep.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4534,6 +4560,7 @@ fn test_resolved_type_single_level_default_materialized() { false, false, json!({ + "$id": "gts://gts.x.ep.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4561,6 +4588,7 @@ fn test_resolved_type_abstract_full_artifacts() { .register_schema( "gts.x.p3.tr.base.v1~", &json!({ + "$id": "gts://gts.x.p3.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-abstract": true, @@ -4580,6 +4608,7 @@ fn test_resolved_type_abstract_full_artifacts() { true, false, json!({ + "$id": "gts://gts.x.p3.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-abstract": true, @@ -4611,6 +4640,7 @@ fn test_resolved_type_final_flag() { .register_schema( "gts.x.fin.tr.base.v1~", &json!({ + "$id": "gts://gts.x.fin.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-final": true, @@ -4626,6 +4656,7 @@ fn test_resolved_type_final_flag() { false, true, json!({ + "$id": "gts://gts.x.fin.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-final": true, @@ -4645,6 +4676,7 @@ fn test_resolved_type_false_traits_schema() { .register_schema( "gts.x.t5.tr.base.v1~", &json!({ + "$id": "gts://gts.x.t5.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4660,6 +4692,7 @@ fn test_resolved_type_false_traits_schema() { false, false, json!({ + "$id": "gts://gts.x.t5.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4680,6 +4713,7 @@ fn test_resolved_type_true_traits_schema() { .register_schema( "gts.x.t6.tr.base.v1~", &json!({ + "$id": "gts://gts.x.t6.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": true, @@ -4695,6 +4729,7 @@ fn test_resolved_type_true_traits_schema() { false, false, json!({ + "$id": "gts://gts.x.t6.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": true, @@ -4712,6 +4747,7 @@ fn test_validate_payload_ok_and_reject() { .register_schema( "gts.x.vp.tr.base.v1~", &json!({ + "$id": "gts://gts.x.vp.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": ["id"], @@ -4739,6 +4775,7 @@ fn test_validate_payload_rejects_abstract_type() { .register_schema( "gts.x.vp.tr.abs.v1~", &json!({ + "$id": "gts://gts.x.vp.tr.abs.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-abstract": true, @@ -4760,6 +4797,7 @@ fn test_schema_traits_ok_and_type_error() { .register_schema( "gts.x.vt.tr.good.v1~", &json!({ + "$id": "gts://gts.x.vt.tr.good.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": {"type": "object", "properties": { @@ -4775,6 +4813,7 @@ fn test_schema_traits_ok_and_type_error() { .register_schema( "gts.x.vt.tr.bad.v1~", &json!({ + "$id": "gts://gts.x.vt.tr.bad.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": {"type": "object", "properties": { @@ -4794,6 +4833,7 @@ fn test_schema_traits_prohibited_by_false_schema() { .register_schema( "gts.x.vt.tr.no_good.v1~", &json!({ + "$id": "gts://gts.x.vt.tr.no_good.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": false @@ -4806,6 +4846,7 @@ fn test_schema_traits_prohibited_by_false_schema() { .register_schema( "gts.x.vt.tr.no_bad.v1~", &json!({ + "$id": "gts://gts.x.vt.tr.no_bad.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": false, @@ -4827,6 +4868,7 @@ fn test_trait_schema_resolves_local_defs_ref() { .register_schema( "gts.x.dr.tr.good.v1~", &json!({ + "$id": "gts://gts.x.dr.tr.good.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4850,6 +4892,7 @@ fn test_trait_schema_resolves_local_defs_ref() { .register_schema( "gts.x.dr.tr.bad.v1~", &json!({ + "$id": "gts://gts.x.dr.tr.bad.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {"id": {"type": "string"}}, @@ -4883,6 +4926,7 @@ fn test_trait_schema_cross_doc_fragment_ref_does_not_break_validation() { .register_schema( "gts.x.cd.tr.base.v1~", &json!({ + "$id": "gts://gts.x.cd.tr.base.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": { @@ -4896,6 +4940,7 @@ fn test_trait_schema_cross_doc_fragment_ref_does_not_break_validation() { .register_schema( "gts.x.cd.tr.base.v1~x.cd._.derived.v1~", &json!({ + "$id": "gts://gts.x.cd.tr.base.v1~x.cd._.derived.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": { @@ -4919,6 +4964,7 @@ fn test_trait_schema_cross_doc_fragment_ref_does_not_break_validation() { .register_schema( "gts.x.cd.tr.base.v1~x.cd._.bad.v1~", &json!({ + "$id": "gts://gts.x.cd.tr.base.v1~x.cd._.bad.v1~", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "x-gts-traits-schema": { @@ -4958,6 +5004,7 @@ fn register_chain_schema(store: &mut GtsStore, id: &str, extra: Value) { "$schema".to_owned(), json!("http://json-schema.org/draft-07/schema#"), ); + doc.insert("$id".to_owned(), json!(format!("gts://{id}"))); doc.insert("type".to_owned(), json!("object")); if let Value::Object(m) = extra { for (k, v) in m { @@ -5046,10 +5093,12 @@ fn test_op13_chain3_schema_composed_across_two_levels() { let mid = "gts.x.c3c.tr.base.v1~x.c3c._.mid.v1~"; let leaf = "gts.x.c3c.tr.base.v1~x.c3c._.mid.v1~x.c3c._.leaf.v1~"; + // Abstract, so leaving the required traits for the leaf does not make the + // ancestors invalid — a leaf inherits its ancestors' validity. register_chain_schema( &mut store, base, - json!({"x-gts-traits-schema": { + json!({"x-gts-abstract": true, "x-gts-traits-schema": { "type": "object", "properties": {"retention": {"type": "string"}}, "required": ["retention"] @@ -5058,7 +5107,7 @@ fn test_op13_chain3_schema_composed_across_two_levels() { register_chain_schema( &mut store, mid, - json!({"x-gts-traits-schema": { + json!({"x-gts-abstract": true, "x-gts-traits-schema": { "type": "object", "properties": {"tier": {"type": "string", "enum": ["gold", "silver"]}}, "required": ["tier"] @@ -5359,7 +5408,11 @@ fn test_op13_trait_schema_allof_ref_resolves_default_and_enforces() { dflt, false, false, - json!({"$schema": "http://json-schema.org/draft-07/schema#", "type": "object"}), + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": format!("gts://{dflt}"), + "type": "object" + }), json!({"retention": "P30D"}), expected_traits_schema.clone(), ); @@ -5381,6 +5434,7 @@ fn test_op13_trait_schema_allof_ref_resolves_default_and_enforces() { false, json!({ "$schema": "http://json-schema.org/draft-07/schema#", + "$id": format!("gts://{ok}"), "type": "object", "x-gts-traits": {"retention": "P365D"} }), @@ -6568,7 +6622,12 @@ fn test_with_transient_entity_removes_the_document_afterwards() { fn test_with_transient_entity_restores_a_displaced_entity_on_panic() { let mut store = GtsStore::new(); store - .register_schema("gts.x.tr.pkg.doc.v1~", &json!({"type": "string"})) + .register_schema( + "gts.x.tr.pkg.doc.v1~", + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.tr.pkg.doc.v1~","type": "string"}), + ) .expect("registers"); let replacement = GtsEntity::new( @@ -6662,7 +6721,7 @@ fn test_wildcard_trait_ref_does_not_vouch_for_an_exact_one() { } #[test] -fn test_trait_ref_into_an_unknown_type_is_tolerated() { +fn test_trait_ref_into_an_unknown_type_is_rejected_unless_unchecked() { let mut store = store_with_trait_chain( &json!({ "type": "object", @@ -6672,10 +6731,19 @@ fn test_trait_ref_into_an_unknown_type_is_tolerated() { }), &json!({"topic": "gts.x.elsewhere.pkg.topic.v1~x.tv._.orders.v1"}), ); + let leaf = "gts.x.tv.pkg.event.v1~x.tv._.leaf.v1~"; + + let err = store + .validate_schema(leaf) + .expect_err("the constraint type is not registered"); + assert!( + err.to_string().contains("gts.x.elsewhere.pkg.topic.v1~"), + "{err}" + ); store - .validate_schema("gts.x.tv.pkg.event.v1~x.tv._.leaf.v1~") - .expect("an unverifiable reference must not fail validation"); + .validate_schema_with(leaf, GtsRefValidation::None) + .expect("none mode does not consult the registry"); } /// A registry that answers point lookups but cannot be enumerated - the shape @@ -6809,6 +6877,16 @@ fn test_validate_payload_rejects_a_deep_document_it_cannot_afford_to_explain() { }), ) .expect("register type"); + store + .register_schema( + "gts.vendor.package.namespace.target.v1.0~", + &json!({ + "$id": "gts://gts.vendor.package.namespace.target.v1.0~", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + }), + ) + .expect("register constraint target"); let mut payload = json!({"ref": "gts.other.package.namespace.target.v1.0~x.v._.bad.v1"}); for _ in 0..40 { @@ -6867,3 +6945,962 @@ fn test_trait_existence_covers_references_in_branches_the_walk_skips() { "the fixtures must fail on existence, not on their pattern: {err}" ); } + +#[test] +fn test_validate_schema_rejects_type_with_invalid_ancestor() { + let mut store = GtsStore::new(); + let base = "gts.x.trans.tr.base.v1~"; + let leaf = "gts.x.trans.tr.base.v1~x.trans._.leaf.v1~"; + + // Non-abstract, so the unresolved required trait makes the base invalid. + register_chain_schema( + &mut store, + base, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"] + }}), + ); + register_chain_schema( + &mut store, + leaf, + json!({"x-gts-traits": {"retention": "P30D"}}), + ); + + assert!(store.validate_schema(base).is_err()); + let err = store + .validate_schema(leaf) + .expect_err("a locally complete leaf inherits its base's invalidity"); + assert!(format!("{err}").contains(base), "{err}"); +} + +#[test] +fn test_validate_schema_rejects_invalid_ref_target() { + let mut store = GtsStore::new(); + let target = "gts.x.transref.tr.target.v1~"; + let host = "gts.x.transref.tr.host.v1~"; + + register_chain_schema( + &mut store, + target, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"] + }}), + ); + register_chain_schema( + &mut store, + host, + json!({"allOf": [{"$ref": format!("gts://{target}")}]}), + ); + + let err = store + .validate_schema(host) + .expect_err("a reference to an invalid type invalidates the referrer"); + assert!(format!("{err}").contains(target), "{err}"); +} + +#[test] +fn test_validate_schema_accepts_mutually_referencing_types() { + let mut store = GtsStore::new(); + let a = "gts.x.transcycle.tr.a.v1~"; + let b = "gts.x.transcycle.tr.b.v1~"; + + register_chain_schema( + &mut store, + a, + json!({"allOf": [{"$ref": format!("gts://{b}")}]}), + ); + register_chain_schema( + &mut store, + b, + json!({"allOf": [{"$ref": format!("gts://{a}")}]}), + ); + + store + .validate_schema(a) + .expect("a reference cycle is unresolvable, not invalid"); +} + +#[test] +fn test_register_is_atomic_when_validation_rejects() { + let mut ops = crate::ops::GtsOps::new(None, None, 0); + let id = "gts.x.atomic.tr.holder.v1~"; + let schema = |target: &str| { + json!({ + "$id": format!("gts://{id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"ref": {"type": "string", "x-gts-ref": target}} + }) + }; + + let rejected = ops.add_entity(&schema("gts.x.atomic.tr.missing.v1~"), true); + assert!(!rejected.ok, "the constraint target is not registered"); + assert!( + ops.store.get(id).is_none(), + "a rejected registration must commit nothing" + ); + + // The id stays free, so a corrected submission can still take it. + let target = "gts.x.atomic.tr.target.v1~"; + register_chain_schema(&mut ops.store, target, json!({})); + let accepted = ops.add_entity(&schema(target), true); + assert!(accepted.ok, "{}", accepted.error); + assert!(ops.store.get(id).is_some()); +} + +#[test] +fn test_gts_ref_validation_modes_gate_constraint_targets() { + let mut store = GtsStore::new(); + let holder = "gts.x.modes.tr.holder.v1~"; + let target = "gts.x.modes.tr.target.v1~"; + + // Present but invalid: a non-abstract type with an unresolved required trait. + register_chain_schema( + &mut store, + target, + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"] + }}), + ); + register_chain_schema( + &mut store, + holder, + json!({"properties": {"ref": {"type": "string", "x-gts-ref": target}}}), + ); + + store + .validate_schema_with(holder, GtsRefValidation::None) + .expect("none mode ignores the target"); + store + .validate_schema_with(holder, GtsRefValidation::AnyPresent) + .expect("any-present mode accepts a present invalid target"); + store + .validate_schema_with(holder, GtsRefValidation::AnyValid) + .expect_err("any-valid mode rejects an invalid target"); +} + +#[test] +fn test_gts_ref_validation_modes_gate_referenced_values() { + let mut store = GtsStore::new(); + let target = "gts.x.modev.tr.target.v1~"; + let holder = "gts.x.modev.tr.holder.v1~"; + register_chain_schema(&mut store, target, json!({})); + register_chain_schema( + &mut store, + holder, + json!({"properties": {"ref": {"type": "string", "x-gts-ref": target}}}), + ); + + let payload = json!({"ref": format!("{target}x.v._.ghost.v1")}); + store + .validate_payload_with(holder, &payload, GtsRefValidation::None) + .expect("none mode does not look the value up"); + store + .validate_payload_with(holder, &payload, GtsRefValidation::AnyPresent) + .expect_err("any-present mode rejects an unregistered value"); +} + +/// A trait the type requires but nothing supplies, which makes it invalid. +fn unsupplied_required_trait() -> Value { + json!({"x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"] + }}) +} + +#[test] +fn test_validate_schema_decides_each_constraint_target_once() { + // Each type constrains `x-gts-ref` to the next two, wrapping around to the + // first, so type `i` is reachable along Fibonacci(i) paths: validating it + // once per path would not finish. + const LEN: usize = 40; + let id = |i: usize| format!("gts.x.lattice.tr.t{i}.v1~"); + let lattice = |last: &Value| { + let mut store = GtsStore::new(); + for i in 0..LEN { + let properties: serde_json::Map = [(i + 1) % LEN, (i + 2) % LEN] + .into_iter() + .map(|next| { + let target = json!({"type": "string", "x-gts-ref": id(next)}); + (format!("t{next}"), target) + }) + .collect(); + let mut extra = if i + 1 == LEN { + last.clone() + } else { + json!({}) + }; + extra["properties"] = Value::Object(properties); + register_chain_schema(&mut store, &id(i), extra); + } + store + }; + + lattice(&json!({})) + .validate_schema(&id(0)) + .expect("every type in the lattice is valid"); + let err = lattice(&unsupplied_required_trait()) + .validate_schema(&id(0)) + .expect_err("the last type is invalid, and every type reaches it"); + assert!(err.to_string().contains(&id(1)), "{err}"); +} + +#[test] +fn test_a_verdict_resting_on_a_failed_cycle_member_is_withdrawn() { + // `a` and `b` constrain `x-gts-ref` to each other, and `a` is invalid on + // its own. Deciding `a` decides `b` on the assumption that `a` is valid; + // `b`'s verdict must not outlive that assumption. + let mut store = GtsStore::new(); + let base = "gts.x.withdraw.tr.base.v1~"; + let a = "gts.x.withdraw.tr.base.v1~x.withdraw._.a.v1~"; + let b = "gts.x.withdraw.tr.base.v1~x.withdraw._.b.v1~"; + let holder = "gts.x.withdraw.tr.holder.v1~"; + register_chain_schema(&mut store, base, json!({})); + let mut a_schema = unsupplied_required_trait(); + a_schema["properties"] = json!({"peer": {"type": "string", "x-gts-ref": b}}); + register_chain_schema(&mut store, a, a_schema); + register_chain_schema( + &mut store, + b, + json!({"properties": {"peer": {"type": "string", "x-gts-ref": a}}}), + ); + register_chain_schema( + &mut store, + holder, + json!({"properties": { + "note": {"type": "string"}, + "target": {"type": "string", "x-gts-ref": base} + }}), + ); + + // Candidate values are decided in sorted order, so `a`, held by the + // unconstrained `note`, is decided before `b`. + let err = store + .validate_payload(holder, &json!({"note": a, "target": b})) + .expect_err("`b` is invalid because `a` is"); + assert!(err.to_string().contains(b), "{err}"); +} + +#[test] +fn test_a_verdict_does_not_outlive_its_validation() { + // `holder` needs `target` valid, and `target` constrains `x-gts-ref` to + // `dependency`, which is registered only after the first attempt. + let mut store = GtsStore::new(); + let holder = "gts.x.session.tr.holder.v1~"; + let target = "gts.x.session.tr.target.v1~"; + let dependency = "gts.x.session.tr.dependency.v1~"; + let refers_to = |id: &str| json!({"properties": {"ref": {"type": "string", "x-gts-ref": id}}}); + register_chain_schema(&mut store, holder, refers_to(target)); + register_chain_schema(&mut store, target, refers_to(dependency)); + + store + .validate_schema(holder) + .expect_err("`target` refers to an unregistered type"); + register_chain_schema(&mut store, dependency, json!({})); + store + .validate_schema(holder) + .expect("`target` is valid once `dependency` is registered"); +} + +#[test] +fn test_gts_ref_validation_parses_wire_spellings() { + assert_eq!( + GtsRefValidation::parse("none").expect("test"), + GtsRefValidation::None + ); + assert_eq!( + GtsRefValidation::parse("any-present").expect("test"), + GtsRefValidation::AnyPresent + ); + assert_eq!( + GtsRefValidation::parse("any-valid").expect("test"), + GtsRefValidation::AnyValid + ); + assert_eq!(GtsRefValidation::default(), GtsRefValidation::AnyValid); + assert!(GtsRefValidation::parse("unknown").is_err()); +} + +#[test] +fn test_validate_schema_accepts_a_self_referencing_schema() { + let mut store = GtsStore::new(); + let id = "gts.x.recursive.tr.node.v1~"; + register_chain_schema( + &mut store, + id, + json!({"allOf": [{"$ref": format!("gts://{id}")}]}), + ); + + // Recursion is legal JSON Schema: the body cannot be inlined, which is not + // the same as being invalid. + store + .validate_schema(id) + .expect("a recursive schema is valid on its own"); +} + +#[test] +fn test_validate_schema_rejects_malformed_syntax_in_a_cyclic_schema() { + let mut store = GtsStore::new(); + let id = "gts.x.cyclicbad.tr.node.v1~"; + register_chain_schema( + &mut store, + id, + json!({ + "type": 42, + "allOf": [{"$ref": format!("gts://{id}")}] + }), + ); + + let err = store + .validate_schema(id) + .expect_err("an unresolvable document is still held to its dialect"); + assert!( + err.to_string().contains("JSON Schema validation failed"), + "{err}" + ); +} + +const DRAFT_2020_12: &str = "https://json-schema.org/draft/2020-12/schema"; + +#[test] +fn test_validate_schema_rejects_a_leaf_that_changes_the_root_dialect() { + let mut store = GtsStore::new(); + let root = "gts.x.dialect.chain.root.v1~"; + let mid = "gts.x.dialect.chain.root.v1~x.dialect._.mid.v1~"; + let leaf = "gts.x.dialect.chain.root.v1~x.dialect._.mid.v1~x.dialect._.leaf.v1~"; + register_chain_schema(&mut store, root, json!({})); + register_chain_schema(&mut store, mid, json!({})); + register_chain_schema(&mut store, leaf, json!({"$schema": DRAFT_2020_12})); + + store.validate_schema(mid).expect("one dialect throughout"); + let err = store + .validate_schema(leaf) + .expect_err("the root selects the dialect for the whole hierarchy"); + let message = err.to_string(); + assert!( + message.contains(&format!("root type '{root}'")), + "{message}" + ); + assert!(message.contains("Draft 2020-12"), "{message}"); + assert!(message.contains("Draft-07"), "{message}"); +} + +#[test] +fn test_validate_schema_rejects_a_leaf_below_an_intermediate_that_changes_dialect() { + let mut store = GtsStore::new(); + let root = "gts.x.dialect.midchain.root.v1~"; + let mid = "gts.x.dialect.midchain.root.v1~x.dialect._.mid.v1~"; + let leaf = "gts.x.dialect.midchain.root.v1~x.dialect._.mid.v1~x.dialect._.leaf.v1~"; + register_chain_schema(&mut store, root, json!({})); + register_chain_schema(&mut store, mid, json!({"$schema": DRAFT_2020_12})); + register_chain_schema(&mut store, leaf, json!({})); + + let err = store + .validate_schema(leaf) + .expect_err("the leaf matches its root, but its base does not"); + let message = err.to_string(); + assert!(message.contains(&format!("GTS type '{mid}'")), "{message}"); + assert!(message.contains("dialect check failed"), "{message}"); +} + +#[test] +fn test_validate_schema_rejects_a_gts_ref_to_another_dialect() { + let mut store = GtsStore::new(); + let target = "gts.x.dialect.ref.target.v1~"; + let holder = "gts.x.dialect.ref.holder.v1~"; + register_chain_schema(&mut store, target, json!({"$schema": DRAFT_2020_12})); + register_chain_schema( + &mut store, + holder, + json!({"properties": {"item": {"$ref": format!("gts://{target}")}}}), + ); + + let err = store + .validate_schema(holder) + .expect_err("a $ref must not cross dialects"); + assert!(err.to_string().contains("'properties/item/$ref'"), "{err}"); +} + +#[test] +fn test_validate_schema_rejects_a_subschema_of_another_dialect() { + let mut store = GtsStore::new(); + let base = "gts.x.dialect.sub.base.v1~"; + let leaf = "gts.x.dialect.sub.base.v1~x.dialect._.leaf.v1~"; + register_chain_schema( + &mut store, + base, + json!({ + "$schema": DRAFT_2020_12, + "x-gts-traits-schema": { + "$id": "https://example.com/gts/legacy-traits", + "$schema": DRAFT7, + "type": "object" + } + }), + ); + register_chain_schema(&mut store, leaf, json!({"$schema": DRAFT_2020_12})); + let mut cases = vec![(base, "x-gts-traits-schema")]; + for (id, location, extra) in [ + ( + "gts.x.dialect.sub.resource.v1~", + "properties/legacy", + json!({"properties": {"legacy": {"$id": "legacy", "$schema": DRAFT7}}}), + ), + ( + // A subschema switches dialect without starting a resource, too. + "gts.x.dialect.sub.plain.v1~", + "properties/count", + json!({"properties": {"count": {"$schema": DRAFT7, "type": "integer"}}}), + ), + ] { + let mut extra = extra; + extra["$schema"] = json!(DRAFT_2020_12); + register_chain_schema(&mut store, id, extra); + cases.push((id, location)); + } + + for (id, location) in cases { + let message = store + .validate_schema(id) + .expect_err("a type is read under one dialect throughout") + .to_string(); + assert!(message.contains("dialect check failed"), "{message}"); + assert!(message.contains(&format!("'{location}'")), "{message}"); + assert!(message.contains("must not change dialect"), "{message}"); + } + store + .validate_schema(leaf) + .expect_err("the leaf inherits the conflicting trait schema"); +} + +#[test] +fn test_validate_schema_rejects_a_pre_draft_07_dialect() { + let mut store = GtsStore::new(); + let id = "gts.x.dialect.legacy.type.v1~"; + register_chain_schema( + &mut store, + id, + json!({"$schema": "http://json-schema.org/draft-06/schema#"}), + ); + + let err = store + .validate_schema(id) + .expect_err("Draft-07 is the minimum supported dialect"); + assert!( + err.to_string().contains("minimum supported dialect"), + "{err}" + ); +} + +#[test] +fn test_trait_self_reference_names_the_selected_leaf() { + // §9.6: `/$id` names the leaf selected for validation, and keeps naming it + // when the constraint is inherited. The composed trait schema carries no + // `$id` of its own, so the selected type must reach the keyword directly. + let mut store = GtsStore::new(); + let base = "gts.x.selfref.tr.base.v1~"; + let leaf = "gts.x.selfref.tr.base.v1~x.selfref._.leaf.v1~"; + let sibling = "gts.x.selfref.tr.base.v1~x.selfref._.sibling.v1~"; + register_chain_schema( + &mut store, + base, + json!({ + "x-gts-abstract": true, + "x-gts-traits-schema": { + "type": "object", + "properties": {"owner": {"type": "string", "x-gts-ref": "/$id"}} + } + }), + ); + register_chain_schema(&mut store, leaf, json!({"x-gts-traits": {"owner": leaf}})); + register_chain_schema( + &mut store, + sibling, + json!({"x-gts-traits": {"owner": base}}), + ); + + store + .validate_schema(base) + .expect("the declaring type compiles its own self-reference"); + store + .validate_schema(leaf) + .expect("the leaf's own id satisfies the inherited self-reference"); + let err = store + .validate_schema(sibling) + .expect_err("an ancestor does not match a self-reference rooted at the leaf"); + let message = err.to_string(); + assert!(message.contains("does not match pattern"), "{message}"); + assert!(message.contains(sibling), "{message}"); +} + +#[test] +fn test_validate_payload_accepts_instances_of_a_cross_type_ref_cycle() { + let mut store = GtsStore::new(); + let a = "gts.x.cycle.pl.node_a.v1~"; + let b = "gts.x.cycle.pl.node_b.v1~"; + register_chain_schema( + &mut store, + a, + json!({"properties": { + "name": {"type": "string"}, + "b": {"$ref": format!("gts://{b}")} + }}), + ); + register_chain_schema( + &mut store, + b, + json!({"properties": {"a": {"$ref": format!("gts://{a}")}}}), + ); + + store + .validate_schema(a) + .expect("a reference cycle is legal"); + store + .validate_payload(a, &json!({})) + .expect("an empty object satisfies both types"); + store + .validate_payload(a, &json!({"b": {"a": {"b": {"a": {"name": "deep"}}}}})) + .expect("the cycle unrolls as deep as the instance goes"); + let err = store + .validate_payload(a, &json!({"b": {"a": {"b": {"a": {"name": 5}}}}})) + .expect_err("constraints still apply on the far side of the cycle"); + assert!(err.to_string().contains("Validation failed"), "{err}"); + store + .validate_payload(b, &json!({"a": {"b": {}}})) + .expect("either member of the cycle can be validated against"); +} + +#[test] +fn test_validate_payload_follows_a_cycle_that_does_not_include_the_type() { + // `holder` reaches a cycle between `left` and `right` without being on it, + // and the cycle's own constraints still apply to the instance. + let mut store = GtsStore::new(); + let holder = "gts.x.cycle.pl.holder.v1~"; + let left = "gts.x.cycle.pl.left.v1~"; + let right = "gts.x.cycle.pl.right.v1~"; + register_chain_schema( + &mut store, + holder, + json!({"properties": {"entry": {"$ref": format!("gts://{left}")}}}), + ); + register_chain_schema( + &mut store, + left, + json!({ + "required": ["depth"], + "properties": { + "depth": {"type": "integer"}, + "next": {"$ref": format!("gts://{right}")} + } + }), + ); + register_chain_schema( + &mut store, + right, + json!({"properties": {"next": {"$ref": format!("gts://{left}")}}}), + ); + + store + .validate_schema(holder) + .expect("a reachable cycle is legal"); + store + .validate_payload( + holder, + &json!({"entry": {"depth": 0, "next": {"next": {"depth": 1}}}}), + ) + .expect("every left node carries a depth"); + store + .validate_payload( + holder, + &json!({"entry": {"depth": 0, "next": {"next": {}}}}), + ) + .expect_err("a left node reached through the cycle still requires depth"); +} + +#[test] +fn test_validate_payload_reads_ref_siblings_by_the_dialect_on_a_cycle_edge() { + // `$ref` keeps its dialect's own semantics (README §9.7, §11; ADR-0001): + // Draft-07 ignores its siblings, Draft 2020-12 applies them. The edge that + // closes a cycle is no exception. + for (dialect, sibling_applies) in [(DRAFT7, false), (DRAFT_2020_12, true)] { + let mut store = GtsStore::new(); + let a = "gts.x.cycle.sib.node_a.v1~"; + let b = "gts.x.cycle.sib.node_b.v1~"; + register_chain_schema( + &mut store, + a, + json!({"$schema": dialect, "properties": {"b": {"$ref": format!("gts://{b}")}}}), + ); + register_chain_schema( + &mut store, + b, + json!({ + "$schema": dialect, + "properties": {"a": {"$ref": format!("gts://{a}"), "required": ["tag"]}} + }), + ); + + store + .validate_payload(a, &json!({"b": {"a": {"tag": "t"}}})) + .unwrap_or_else(|e| panic!("{dialect}: the sibling is satisfied: {e}")); + let missing_tag = store.validate_payload(a, &json!({"b": {"a": {}}})); + assert_eq!( + missing_tag.is_err(), + sibling_applies, + "{dialect}: {missing_tag:?}" + ); + } +} + +#[test] +fn test_validate_payload_follows_a_local_recursion_inside_a_referenced_type() { + // A pointer recursion inside another type cannot be inlined either; it + // must keep resolving against that type, not the one being validated. + let mut store = GtsStore::new(); + let holder = "gts.x.cycle.local.holder.v1~"; + let tree = "gts.x.cycle.local.tree.v1~"; + register_chain_schema( + &mut store, + holder, + json!({"properties": {"root": {"$ref": format!("gts://{tree}#/definitions/node")}}}), + ); + register_chain_schema( + &mut store, + tree, + json!({"definitions": {"node": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "kids": {"type": "array", "items": {"$ref": "#/definitions/node"}} + } + }}}), + ); + + store + .validate_schema(holder) + .expect("recursion inside a target is legal"); + store + .validate_payload( + holder, + &json!({"root": {"kids": [{"kids": [{"label": "leaf"}]}]}}), + ) + .expect("a well-formed tree"); + store + .validate_payload( + holder, + &json!({"root": {"kids": [{"kids": [{"label": 1}]}]}}), + ) + .expect_err("a node deep in the tree is still a node"); +} + +#[test] +fn test_validate_payload_rejects_violations_through_a_mutual_all_of_cycle() { + // A and B each `allOf` the other: validation re-enters at the same instance + // location, and both types' own constraints still apply. + let mut store = GtsStore::new(); + let a = "gts.x.cycle.allof.node_a.v1~"; + let b = "gts.x.cycle.allof.node_b.v1~"; + register_chain_schema( + &mut store, + a, + json!({ + "allOf": [{"$ref": format!("gts://{b}")}], + "properties": {"id": {"type": "string"}} + }), + ); + register_chain_schema( + &mut store, + b, + json!({ + "allOf": [{"$ref": format!("gts://{a}")}], + "properties": {"name": {"type": "string"}} + }), + ); + + for (id, other) in [(a, b), (b, a)] { + store + .validate_schema(id) + .unwrap_or_else(|e| panic!("{id}: a mutual allOf cycle is legal: {e}")); + store + .validate_payload(id, &json!({})) + .unwrap_or_else(|e| panic!("{id}: an empty object satisfies both: {e}")); + store + .validate_payload(id, &json!({"id": "i", "name": "n"})) + .unwrap_or_else(|e| panic!("{id}: conforming values: {e}")); + for invalid in [json!({"id": 5}), json!({"name": 5}), json!("not an object")] { + store.validate_payload(id, &invalid).expect_err(&format!( + "{id} (cycling through {other}) must reject {invalid}" + )); + } + } +} + +/// A Draft 2020-12 type holding an embedded resource at `inner`. +/// +/// The document root has a decoy `$defs/n`: resolving `inner`'s own +/// references from the document root lands there instead of inside `inner`. +fn embedded_resource_in_2020_12(id: &str) -> Value { + json!({ + "$schema": DRAFT_2020_12, + "$id": format!("gts://{id}"), + "type": "object", + "$defs": {"n": {"type": "string"}}, + "properties": { + "inner": { + "$id": "inner", + "type": "object", + "properties": { + "n": {"$ref": "#/$defs/n"}, + "next": {"$ref": "#"} + }, + "$defs": {"n": {"type": "integer"}} + } + } + }) +} + +#[test] +fn test_local_refs_inside_an_embedded_resource_resolve_from_that_resource() { + let mut store = GtsStore::new(); + let id = "gts.x.embedded.inner.type.v1~"; + store + .register_schema(id, &embedded_resource_in_2020_12(id)) + .expect("register"); + + let resolved = store.validate_schema(id).expect("every reference resolves"); + assert_eq!( + resolved.schema.pointer("/properties/inner/properties/n"), + Some(&json!({"type": "integer"})), + "`#/$defs/n` names inner's definition, not the document's" + ); + + for valid in [ + json!({"inner": {"n": 1}}), + json!({"inner": {"n": 1, "next": {"n": 2, "next": {"n": 3}}}}), + ] { + store + .validate_payload(id, &valid) + .unwrap_or_else(|e| panic!("{valid}: {e}")); + } + for invalid in [ + json!({"inner": {"n": "one"}}), + // `#` names `inner`, so `next` is another inner node. + json!({"inner": {"next": {"n": "two"}}}), + json!({"inner": {"next": {"next": {"n": "three"}}}}), + ] { + store + .validate_payload(id, &invalid) + .expect_err(&format!("{invalid} must be rejected")); + } +} + +#[test] +fn test_a_pointer_into_an_embedded_resource_keeps_its_recursion_resolvable() { + // `root` points into `tree` from outside it, so the copy placed there has no + // `$id` of its own: the recursion inside must still name `tree`'s node. + let mut store = GtsStore::new(); + let id = "gts.x.embedded.cross.tree.v1~"; + register_chain_schema( + &mut store, + id, + json!({ + "properties": {"root": {"$ref": "#/definitions/tree/definitions/node"}}, + "definitions": { + "tree": { + "$id": "tree", + "definitions": {"node": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "kids": {"type": "array", "items": {"$ref": "#/definitions/node"}} + } + }} + } + } + }), + ); + + store + .validate_schema(id) + .expect("the tree's pointer resolves inside it"); + store + .validate_payload( + id, + &json!({"root": {"kids": [{"kids": [{"label": "leaf"}]}]}}), + ) + .expect("a well-formed tree"); + store + .validate_payload(id, &json!({"root": {"kids": [{"kids": [{"label": 1}]}]}})) + .expect_err("a node deep in the tree is still a node"); +} + +#[test] +fn test_an_embedded_resource_of_a_referenced_type_resolves_from_itself() { + let mut store = GtsStore::new(); + let lib = "gts.x.embedded.lib.type.v1~"; + let holder = "gts.x.embedded.lib.holder.v1~"; + store + .register_schema(lib, &embedded_resource_in_2020_12(lib)) + .expect("register lib"); + store + .register_schema( + holder, + &json!({ + "$schema": DRAFT_2020_12, + "$id": format!("gts://{holder}"), + "type": "object", + "properties": {"item": {"$ref": format!("gts://{lib}")}} + }), + ) + .expect("register holder"); + + store.validate_schema(holder).expect("the holder is valid"); + store + .validate_payload( + holder, + &json!({"item": {"inner": {"n": 1, "next": {"next": {"n": 2}}}}}), + ) + .expect("inner nodes all the way down"); + for invalid in [ + json!({"item": {"inner": {"n": "one"}}}), + json!({"item": {"inner": {"next": {"n": "two"}}}}), + json!({"item": {"inner": {"next": {"next": {"n": "three"}}}}}), + ] { + store + .validate_payload(holder, &invalid) + .expect_err(&format!("{invalid} must be rejected")); + } +} + +#[test] +fn test_a_trait_schema_resolves_an_embedded_resource_from_itself() { + let mut store = GtsStore::new(); + let traits_schema = json!({ + "type": "object", + "properties": {"limits": { + "$id": "limits", + "type": "object", + "properties": {"max": {"$ref": "#/definitions/n"}}, + "definitions": {"n": {"type": "integer"}} + }} + }); + for (id, max, valid) in [ + ("gts.x.embedded.traits.good.v1~", json!(5), true), + ("gts.x.embedded.traits.bad.v1~", json!("five"), false), + ] { + register_chain_schema( + &mut store, + id, + json!({ + // Decoy: the host's own `definitions/n` is not `limits`'. + "definitions": {"n": {"type": "string"}}, + "x-gts-traits-schema": traits_schema.clone(), + "x-gts-traits": {"limits": {"max": max}} + }), + ); + let outcome = store.validate_schema(id); + assert_eq!(outcome.is_ok(), valid, "{id}: {outcome:?}"); + } +} + +#[test] +fn test_a_pointer_from_outside_into_an_embedded_resource() { + let mut store = GtsStore::new(); + let id = "gts.x.embedded.outside.type.v1~"; + store + .register_schema( + id, + &json!({ + "$schema": DRAFT_2020_12, + "$id": format!("gts://{id}"), + "type": "object", + "properties": { + "count": {"$ref": "#/$defs/inner/$defs/n"} + }, + "$defs": {"inner": { + "$id": "inner", + "$defs": {"n": {"type": "integer"}} + }} + }), + ) + .expect("register"); + + store + .validate_schema(id) + .expect("the pointer resolves into the embedded resource"); + store + .validate_payload(id, &json!({"count": 3})) + .expect("an integer"); + store + .validate_payload(id, &json!({"count": "three"})) + .expect_err("the pointer still names inner's integer definition"); +} + +#[test] +fn test_an_embedded_resource_stays_reachable_at_root_and_inside() { + // `inner` recurses through `#`, and is reached from outside both at its + // root (same document and from another type) and inside it. + let mut store = GtsStore::new(); + let lib = "gts.x.embedded.reach.lib.v1~"; + let user = "gts.x.embedded.reach.user.v1~"; + store + .register_schema( + lib, + &json!({ + "$schema": DRAFT_2020_12, + "$id": format!("gts://{lib}"), + "type": "object", + "properties": { + "count": {"$ref": "#/$defs/inner/$defs/n"}, + "chain": {"$ref": "#/$defs/inner"} + }, + "$defs": {"inner": { + "$id": "inner", + "type": "object", + "properties": { + "n": {"$ref": "#/$defs/n"}, + "next": {"$ref": "#"} + }, + "$defs": {"n": {"type": "integer"}} + }} + }), + ) + .expect("register lib"); + register_chain_schema( + &mut store, + user, + json!({ + "$schema": DRAFT_2020_12, + "properties": {"chain": {"$ref": format!("gts://{lib}#/$defs/inner")}} + }), + ); + + store.validate_schema(lib).expect("lib is valid"); + store.validate_schema(user).expect("user is valid"); + store + .validate_payload( + lib, + &json!({"count": 1, "chain": {"n": 2, "next": {"n": 3}}}), + ) + .expect("conforming lib instance"); + store + .validate_payload(user, &json!({"chain": {"next": {"next": {"n": 4}}}})) + .expect("conforming user instance"); + for (id, invalid) in [ + (lib, json!({"count": "one"})), + (lib, json!({"chain": {"n": "two"}})), + (lib, json!({"chain": {"next": {"n": "three"}}})), + (user, json!({"chain": {"n": "four"}})), + (user, json!({"chain": {"next": {"next": {"n": "five"}}}})), + ] { + store + .validate_payload(id, &invalid) + .expect_err(&format!("{id} must reject {invalid}")); + } +} diff --git a/gts/src/testing.rs b/gts/src/testing.rs index b6d9603..4c298bf 100644 --- a/gts/src/testing.rs +++ b/gts/src/testing.rs @@ -9,6 +9,7 @@ use serde_json::Value; use crate::GTS_ID_URI_PREFIX; use crate::ops::GtsOps; +use crate::x_gts_ref::GtsRefValidation; /// Register a base→leaf chain of GTS type schemas and run OP#13 trait validation /// on the leaf. @@ -47,6 +48,10 @@ pub fn validate_traits_chain(chain: &[&Value]) -> Result<(), String> { /// when a set of macro-generated schemas (trait types, hosts, intermediates) /// must *all* be valid and mutually consistent in one registry. /// +/// `x-gts-ref` targets are not looked up: the set under test is the whole +/// registry here, so a constraint naming a type outside it says nothing about +/// whether the set is self-consistent. +/// /// # Errors /// Returns an error if `schemas` is empty, or the error of the first schema that /// fails to register or validate, prefixed with its `$id`. @@ -68,7 +73,7 @@ pub fn validate_all(schemas: &[&Value]) -> Result<(), String> { return Err("schema is missing a string `$id`".to_owned()); }; let gts_id = id.strip_prefix(GTS_ID_URI_PREFIX).unwrap_or(id); - let result = ops.validate_schema(gts_id); + let result = ops.validate_schema_with(gts_id, GtsRefValidation::None); if !result.ok { return Err(format!("{gts_id}: {}", result.error)); } diff --git a/gts/src/x_gts_ref.rs b/gts/src/x_gts_ref.rs index e477d63..55c7c9a 100644 --- a/gts/src/x_gts_ref.rs +++ b/gts/src/x_gts_ref.rs @@ -1,19 +1,19 @@ /// x-gts-ref validation support for GTS schemas. /// -/// This module implements validation for the `x-gts-ref` extension as specified -/// in the GTS specification v0.5, section 9.5. +/// This module implements the `x-gts-ref` extension of GTS specification v0.14, +/// section 9.6. /// /// # Overview /// -/// The `x-gts-ref` extension allows schemas to enforce that string values must be -/// valid GTS identifiers or match specific patterns. This is useful for ensuring -/// referential integrity in GTS-based systems. +/// The `x-gts-ref` extension constrains a string value to a GTS identifier that +/// matches a given pattern, keeping references in GTS-based systems sound. /// /// # Features /// -/// 1. **Schema Validation**: Validates that `x-gts-ref` fields in schemas contain valid patterns +/// 1. **Schema Validation**: Validates that `x-gts-ref` declarations name a usable pattern /// 2. **Instance Validation**: Validates that instance values match their `x-gts-ref` constraints -/// 3. **JSON Pointer Resolution**: Supports JSON Pointer references (e.g., `/$id`, `/properties/name`) +/// 3. **Registry Checks**: Presence and validity of the target, as far as +/// [`GtsRefValidation`] asks for /// 4. **GTS ID Pattern Matching**: Validates GTS IDs and prefix patterns (e.g., `gts.x.y._.z.v1~`) /// /// # Examples @@ -64,31 +64,16 @@ /// assert!(errors.is_empty()); /// ``` /// -/// # x-gts-ref Patterns +/// # x-gts-ref Operands /// /// The `x-gts-ref` field can contain: /// -/// - **GTS ID Pattern**: A full or prefix GTS identifier (e.g., `gts.x.y._.z.v1~`) -/// - **JSON Pointer**: A reference to another field in the schema (e.g., `/$id`, `/properties/name`) -/// -/// ## JSON Pointer Resolution -/// -/// When an `x-gts-ref` starts with `/`, it's treated as a JSON Pointer that resolves -/// to a value in the schema. The resolved value must be a valid GTS ID pattern. -/// -/// Example: -/// ```json -/// { -/// "$id": "gts://gts.x.example._.user.v1~", -/// "$schema": "http://json-schema.org/draft-07/schema#", -/// "type": "object", -/// "properties": { -/// "type": {"type": "string", "x-gts-ref": "/$id"} -/// } -/// } -/// ``` -/// -/// In this case, the `type` field must match the schema's `$id` value. +/// - **GTS ID Pattern**: A full or prefix GTS identifier (e.g., `gts.x.y._.z.v1~`), +/// including a wildcard pattern (e.g., `gts.x.y.*`) +/// - **Self-reference**: `/$id`, the identifier of the leaf type being validated, +/// even where the constraint is inherited from a base or trait schema (for a +/// schema validated on its own, its own `$id`). It is the only pointer operand +/// the spec allows; every other slash-prefixed value is rejected. use std::sync::Arc; use jsonschema::error::ValidationErrorKind; @@ -153,10 +138,76 @@ pub(crate) fn candidate_reference_values(instance: &Value) -> Vec { found.into_iter().collect() } +/// The one pointer operand a declaration may use (spec v0.14 §9.6). +const SELF_ID_POINTER: &str = "/$id"; + +/// How far `x-gts-ref` targets are checked (spec v0.14 §9.6). +/// +/// Syntax and pattern conformance are checked in every mode; the mode only +/// decides how much the registry is consulted about the target. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GtsRefValidation { + /// Do not consult the registry. + None, + /// The target must be registered. + AnyPresent, + /// The target must be registered and itself valid. + #[default] + AnyValid, +} + +impl GtsRefValidation { + /// Parses the spelling used by the `gts-ref-validation` request parameter. + /// + /// # Errors + /// Returns the rejected spelling when it names no mode. + pub fn parse(spelling: &str) -> Result { + match spelling { + "none" => Ok(Self::None), + "any-present" => Ok(Self::AnyPresent), + "any-valid" => Ok(Self::AnyValid), + other => Err(format!( + "unknown gts-ref-validation mode '{other}': expected \ + 'none', 'any-present' or 'any-valid'" + )), + } + } + + /// Whether the registry is consulted at all. + #[must_use] + pub fn checks_registry(self) -> bool { + self != Self::None + } + + /// Whether the target must itself validate. + #[must_use] + pub fn checks_validity(self) -> bool { + self == Self::AnyValid + } +} + +/// Every usable `x-gts-ref` pattern the document declares, by location. +/// +/// Unusable declarations are skipped: [`XGtsRefValidator::validate_schema`] +/// already reports them. +pub(crate) fn declared_patterns(schema: &Value) -> Vec<(String, GtsIdPattern)> { + let selected = XGtsRefValidator::self_id(schema); + let mut declared = Vec::new(); + crate::schema_modifiers::for_each_schema_node(schema, &mut |node, location| { + if let Some(value) = node.get(X_GTS_REF) + && let Ok(pattern) = resolve_declaration(value, selected.as_deref()) + { + declared.push((declaration_path("", location), pattern)); + } + }); + declared +} + /// The pattern a declaration denotes, or why it is not a usable declaration. /// -/// Accepts a GTS pattern or a JSON Pointer resolving to one. -fn resolve_declaration(declared: &Value, root: &Value) -> Result { +/// Accepts a GTS pattern or the `/$id` self-reference, which names `selected`: +/// the type being validated (spec v0.14 §9.6). +fn resolve_declaration(declared: &Value, selected: Option<&str>) -> Result { let Some(declared) = declared.as_str() else { return Err(format!("x-gts-ref value must be a string, got {declared}")); }; @@ -166,11 +217,11 @@ fn resolve_declaration(declared: &Value, root: &Value) -> Result '{resolved}' is not a valid GTS identifier: {}", e.cause @@ -179,22 +230,23 @@ fn resolve_declaration(declared: &Value, root: &Value) -> Result, exists: Option, ) -> jsonschema::ValidationOptions { - let root = Arc::new(root.clone()); options.with_keyword(X_GTS_REF, move |_parent, declared, _location| { - let pattern = - resolve_declaration(declared, &root).map_err(jsonschema::ValidationError::schema)?; + let pattern = resolve_declaration(declared, selected.as_deref()) + .map_err(jsonschema::ValidationError::schema)?; Ok(Box::new(XGtsRefKeyword { pattern, exists: exists.clone(), @@ -233,8 +285,8 @@ impl XGtsRefKeyword { } } -impl jsonschema::Keyword for XGtsRefKeyword { - fn validate<'i>(&self, instance: &'i Value) -> Result<(), jsonschema::ValidationError<'i>> { +impl<'i> jsonschema::Keyword<'i> for XGtsRefKeyword { + fn validate(&self, instance: &'i Value) -> Result<(), jsonschema::ValidationError<'i>> { match self.violation(instance) { Some(reason) => Err(jsonschema::ValidationError::custom(reason)), None => Ok(()), @@ -419,14 +471,14 @@ impl XGtsRefValidator { schema_path: &str, root_schema: Option<&Value>, ) -> Vec { - let root = root_schema.unwrap_or(schema); + let selected = Self::self_id(root_schema.unwrap_or(schema)); let mut errors = Vec::new(); crate::schema_modifiers::for_each_schema_node(schema, &mut |node, location| { let Some(declared) = node.get(X_GTS_REF) else { return; }; - if let Err(reason) = resolve_declaration(declared, root) { + if let Err(reason) = resolve_declaration(declared, selected.as_deref()) { // Non-string declarations have no pattern to report. let (value, ref_pattern) = declared.as_str().map_or_else( || (format!("{declared:?}"), String::new()), @@ -444,45 +496,13 @@ impl XGtsRefValidator { errors } - /// Resolve a JSON Pointer against the schema root. - /// - /// Uses `serde_json`'s RFC 6901 implementation, including arrays and escapes. - /// - /// # Returns - /// The resolved value as a string or None if not found. - /// Note: For `/$id` references, the `gts://` prefix is stripped from the value - /// as per GTS specification (relative self-reference should match the $id without the prefix). - fn resolve_pointer(schema: &Value, pointer: &str) -> Option { - Self::resolve_pointer_inner(schema, pointer, 0) - } - - /// Depth-guarded pointer resolution: relative `x-gts-ref` hops recurse here, - /// and a self-referential chain would overflow the stack without the cap. - fn resolve_pointer_inner(schema: &Value, pointer: &str, depth: usize) -> Option { - const MAX_POINTER_DEPTH: usize = 64; - if depth > MAX_POINTER_DEPTH { - return None; - } - - let current = schema.pointer(pointer)?; - - // If current is a string, return it (stripping gts:// prefix if present) - if let Some(s) = current.as_str() { - return Some(Self::strip_gts_uri_prefix(s)); - } - - // If current is an object with x-gts-ref, resolve it - if let Some(obj) = current.as_object() - && let Some(ref_value) = obj.get(X_GTS_REF) - && let Some(ref_str) = ref_value.as_str() - { - if ref_str.starts_with('/') { - return Self::resolve_pointer_inner(schema, ref_str, depth + 1); - } - return Some(ref_str.to_owned()); - } - - None + /// The document's own GTS identifier, as `/$id` denotes it when the + /// document itself is the type being validated. + pub(crate) fn self_id(schema: &Value) -> Option { + schema + .pointer(SELF_ID_POINTER) + .and_then(Value::as_str) + .map(Self::strip_gts_uri_prefix) } /// Strip the `gts://` prefix from a value if present. diff --git a/gts/src/x_gts_ref_test.rs b/gts/src/x_gts_ref_test.rs index 6fe8a21..4bd2264 100644 --- a/gts/src/x_gts_ref_test.rs +++ b/gts/src/x_gts_ref_test.rs @@ -139,8 +139,7 @@ mod tests { } #[test] - fn test_validate_schema_relative_ref_resolving_to_wildcard() { - // Relative declarations accept the same wildcards as literals. + fn test_validate_schema_rejects_pointer_other_than_self_id() { let validator = XGtsRefValidator::new(); let schema = json!({ "type": "object", @@ -157,10 +156,8 @@ mod tests { }); let errors = validator.validate_schema(&schema, "", None); - assert!( - errors.is_empty(), - "relative ref resolving to a wildcard must be accepted: {errors:?}" - ); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].reason.contains("/$id"), "{:?}", errors[0]); } #[test] @@ -304,18 +301,16 @@ mod tests { #[test] fn a_literal_declaration_is_judged_by_the_pattern_parser() { - let no_root = json!({}); - for ok in ["gts.x.core.events.topic.v1~", "gts.*", "gts.x.core.*"] { assert!( - resolve_declaration(&json!(ok), &no_root).is_ok(), + resolve_declaration(&json!(ok), None).is_ok(), "expected '{ok}' to validate" ); } // Reject malformed wildcard placement. for bad in ["gts.x.*.events.*", "gts.*.*.*.*"] { - let reason = resolve_declaration(&json!(bad), &no_root) + let reason = resolve_declaration(&json!(bad), None) .expect_err("expected '{bad}' to be rejected"); assert!(!reason.is_empty()); } @@ -336,28 +331,28 @@ mod tests { #[test] fn a_declaration_is_resolved_the_same_way_everywhere() { // Compilation and schema validation share declaration rules. - let with_id = json!({"$id": "gts://gts.x.test._.entity.v1~"}); - let no_root = json!({}); + let selected = Some("gts.x.test._.entity.v1~"); - assert!(resolve_declaration(&json!("gts.x.y.z.w.v1~"), &no_root).is_ok()); - assert!(resolve_declaration(&json!("/$id"), &with_id).is_ok()); + assert!(resolve_declaration(&json!("gts.x.y.z.w.v1~"), None).is_ok()); + assert!(resolve_declaration(&json!("/$id"), selected).is_ok()); - for (declared, root, expected) in [ - ("gts.INVALID", &no_root, "Invalid GTS identifier"), - ("/nonexistent", &no_root, "Cannot resolve reference path"), - ("invalid-format", &no_root, "must start with 'gts.' or '/'"), + for (declared, selected, expected) in [ + ("gts.INVALID", None, "Invalid GTS identifier"), + ("/$id", None, "Cannot resolve reference path"), + ("/properties/anchor", selected, "self-reference '/$id'"), + ("./$id", selected, "self-reference '/$id'"), + ("invalid-format", None, "must start with 'gts.'"), ] { - let reason = resolve_declaration(&json!(declared), root) + let reason = resolve_declaration(&json!(declared), selected) .expect_err("declaration must be rejected"); assert!(reason.contains(expected), "{declared}: {reason}"); } - let lands_on_junk = json!({"notAnId": "not-a-valid-gts-id"}); - let reason = resolve_declaration(&json!("/notAnId"), &lands_on_junk) - .expect_err("a pointer onto a non-identifier must be rejected"); + let reason = resolve_declaration(&json!("/$id"), Some("not-a-valid-gts-id")) + .expect_err("a self-reference onto a non-identifier must be rejected"); assert!(reason.contains("not a valid GTS identifier"), "{reason}"); - let reason = resolve_declaration(&json!(123), &no_root) + let reason = resolve_declaration(&json!(123), None) .expect_err("a non-string declaration must be rejected"); assert!(reason.contains("must be a string"), "{reason}"); } @@ -375,13 +370,13 @@ mod tests { .is_empty() ); - // Unusable pointer targets make the schema invalid. + // Unusable operands make the schema invalid. for broken in [ json!({ "someField": "not-a-gts-pattern", "properties": {"r": {"type": "string", "x-gts-ref": "/someField"}} }), - json!({"properties": {"r": {"type": "string", "x-gts-ref": "/missing"}}}), + json!({"properties": {"r": {"type": "string", "x-gts-ref": "/$id"}}}), ] { let errors = validator.validate_instance(&json!({"r": "some-value"}), &broken, ""); assert_eq!(errors.len(), 1, "{errors:?}"); @@ -392,7 +387,7 @@ mod tests { ); } - let missing = json!({"properties": {"r": {"type": "string", "x-gts-ref": "/missing"}}}); + let missing = json!({"properties": {"r": {"type": "string", "x-gts-ref": "/$id"}}}); let reported = validator.validate_schema(&missing, "", None); assert_eq!(reported.len(), 1, "{reported:?}"); assert_eq!(reported[0].field_path, "properties/r/x-gts-ref"); @@ -404,89 +399,15 @@ mod tests { } #[test] - fn test_resolve_pointer() { + fn test_self_id() { let schema = json!({"$id": "gts://gts.x.test._.entity.v1~"}); assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/$id"), + XGtsRefValidator::self_id(&schema), Some("gts.x.test._.entity.v1~".to_owned()) ); - let schema = json!({ - "properties": { - "name": { - "x-gts-ref": "gts.x.test.*" - } - } - }); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/properties/name/x-gts-ref"), - Some("gts.x.test.*".to_owned()) - ); - - let schema = json!({"properties": {}}); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/nonexistent"), - None - ); - - let schema = json!({"$id": "test"}); - assert_eq!(XGtsRefValidator::resolve_pointer(&schema, "/"), None); - - let schema = json!({"value": "string"}); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/value/nested"), - None - ); - - let schema = json!({ - "$id": "gts://gts.x.test._.entity.v1~", - "properties": { - "type": { - "x-gts-ref": "/$id" - } - } - }); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/properties/type"), - Some("gts.x.test._.entity.v1~".to_owned()) - ); - - let schema = json!({ - "$id": "gts://gts.x.test._.entity.v1~", - "type": "gts://gts.x.another._.type.v1~" - }); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/$id"), - Some("gts.x.test._.entity.v1~".to_owned()) - ); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/type"), - Some("gts.x.another._.type.v1~".to_owned()) - ); - } - - #[test] - fn test_resolve_pointer_self_cycle_terminates() { - let schema = json!({ - "properties": { - "a": { "x-gts-ref": "/properties/a" } - } - }); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/properties/a"), - None - ); - - let schema = json!({ - "properties": { - "a": { "x-gts-ref": "/properties/b" }, - "b": { "x-gts-ref": "/properties/a" } - } - }); - assert_eq!( - XGtsRefValidator::resolve_pointer(&schema, "/properties/a"), - None - ); + assert_eq!(XGtsRefValidator::self_id(&json!({"properties": {}})), None); + assert_eq!(XGtsRefValidator::self_id(&json!({"$id": {"a": 1}})), None); } #[test] @@ -1736,130 +1657,35 @@ mod applicator_tests { // Relative references (RFC 6901) #[test] - fn a_relative_reference_resolves_escaped_pointer_tokens() { - // RFC 6901 escapes `/` as `~1` and `~` as `~0`. - let schema = json!({ - "$defs": { - "a/b": {"const": "gts.x.a.b.slash.v1~"}, - "gts.x.a.b.tilde.v1~": {"const": "gts.x.a.b.tilde.v1~"} - }, - "type": "object", - "properties": { - "slash": {"type": "string", "x-gts-ref": "/$defs/a~1b/const"}, - "tilde": {"type": "string", "x-gts-ref": "/$defs/gts.x.a.b.tilde.v1~0/const"} - } - }); - - assert!( - is_valid( - &json!({ - "slash": "gts.x.a.b.slash.v1~x.c._.i.v1", - "tilde": "gts.x.a.b.tilde.v1~x.c._.i.v1" - }), - &schema - ), - "both escaped pointers must resolve to their patterns" - ); - - let mut paths = violation_paths( - &json!({ - "slash": "gts.x.a.b.tilde.v1~x.c._.i.v1", - "tilde": "gts.x.a.b.slash.v1~x.c._.i.v1" - }), - &schema, - ); - paths.sort(); - assert_eq!(paths, ["/slash", "/tilde"]); - } - - #[test] - fn an_unescaped_pointer_token_does_not_resolve() { - let schema = json!({ - "$defs": {"a/b": {"const": "gts.x.a.b.slash.v1~"}}, - "type": "object", - "properties": {"r": {"type": "string", "x-gts-ref": "/$defs/a/b/const"}} - }); - - let errors = XGtsRefValidator::new().validate_instance( - &json!({"r": "gts.x.a.b.slash.v1~x.c._.i.v1"}), - &schema, - "", - ); - assert_eq!(errors.len(), 1, "{errors:?}"); - assert!( - errors[0].reason.contains("Cannot resolve reference path"), - "{:?}", - errors[0] - ); - } - - #[test] - fn a_relative_reference_can_point_through_an_array() { - let schema = json!({ - "type": "object", - "examples": ["gts.x.a.b.target.v1~"], - "properties": {"r": {"type": "string", "x-gts-ref": "/examples/0"}} - }); - - assert!(is_valid(&json!({"r": target_id()}), &schema)); - assert_eq!(violation_paths(&json!({"r": other_id()}), &schema), ["/r"]); - } - - #[test] - fn a_relative_reference_follows_a_pointer_chain_to_the_document_id() { - // Follow a pointer chain and strip `gts://` from the document ID. - let schema = json!({ - "$id": "gts://gts.x.testref._.pointer.v1~", - "$schema": "http://json-schema.org/draft-07/schema#", + fn only_the_self_id_pointer_is_a_usable_operand() { + // §9.6 allows `/$id` and nothing else; RFC 6901 pointers are gone. + let self_ref = json!({ + "$id": "gts://gts.x.a.b.target.v1~", "type": "object", - "properties": { - "id": {"type": "string", "x-gts-ref": "/$id"}, - "type": {"type": "string", "x-gts-ref": "/properties/id"} - } + "properties": {"r": {"type": "string", "x-gts-ref": "/$id"}} }); - - assert!( - is_valid( - &json!({ - "id": "gts.x.testref._.pointer.v1~x.vendor._.ptr.v1", - "type": "gts.x.testref._.pointer.v1~" - }), - &schema - ), - "both pointers resolve to the document id" - ); - - let mut paths = violation_paths( - &json!({ - "id": "gts://gts.x.testref._.pointer.v1~", - "type": "gts.x.testref._.wrong.v1~" - }), - &schema, - ); - paths.sort(); + assert!(is_valid(&json!({"r": target_id()}), &self_ref)); assert_eq!( - paths, - ["/id", "/type"], - "the `gts://` form is not an identifier, and a foreign id misses the pattern" + violation_paths(&json!({"r": other_id()}), &self_ref), + ["/r"] ); - } - - #[test] - fn a_bare_slash_pointer_names_the_empty_key_not_the_root() { - // In RFC 6901, `/` names the empty key rather than the root. - let schema = json!({ - "$id": "gts://gts.x.a.b.target.v1~", - "type": "object", - "properties": {"r": {"type": "string", "x-gts-ref": "/"}} - }); - let errors = - XGtsRefValidator::new().validate_instance(&json!({"r": target_id()}), &schema, ""); - assert_eq!(errors.len(), 1, "{errors:?}"); - assert!( - errors[0].reason.contains("Cannot resolve reference path"), - "{:?}", - errors[0] - ); + for pointer in [ + "/", + "/examples/0", + "/$defs/a~1b/const", + "/properties/r", + "./$id", + ] { + let schema = json!({ + "$id": "gts://gts.x.a.b.target.v1~", + "examples": ["gts.x.a.b.target.v1~"], + "$defs": {"a/b": {"const": "gts.x.a.b.target.v1~"}}, + "type": "object", + "properties": {"r": {"type": "string", "x-gts-ref": pointer}} + }); + let errors = XGtsRefValidator::new().validate_schema(&schema, "", None); + assert_eq!(errors.len(), 1, "{pointer}: {errors:?}"); + } } }