Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gts-spec-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.14.3
v0.14.4
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A complete Rust implementation of the Global Type System (GTS)

GTS [Global Type System](https://github.com/globaltypesystem/gts-spec) is a simple, human-readable, globally unique identifier and referencing system for data type definitions (e.g., JSON Schemas) and data instances (e.g., JSON objects). This Rust implementation provides high-performance, type-safe operations for working with GTS identifiers.

Current supported GTS spec version: `0.14.3`
Current supported GTS spec version: `0.14.4`

## Roadmap

Expand Down
10 changes: 8 additions & 2 deletions gts-cli/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,16 +287,22 @@ async fn add_entities(
}

/// Per-entry outcomes are in the body, so a partly rejected batch is still a
/// 200; only a body that is not an array of schemas is refused outright.
/// 200; only a body that is not an array of schemas, or a malformed
/// `gts-ref-validation`, is refused outright.
async fn add_schemas(
State(state): State<AppState>,
Query(params): Query<AddEntityQuery>,
Json(body): Json<Vec<Value>>,
) -> impl IntoResponse {
let refs = match params.refs.resolve() {
Ok(refs) => refs,
Err(error) => return unprocessable(&error),
};
let mut ops = match lock_ops(&state.ops) {
Ok(guard) => guard,
Err(response) => return response.into_response(),
};
let result = ops.add_schemas(&body);
let result = ops.add_schemas_with(&body, params.validate, refs);
Json(result).into_response()
}

Expand Down
56 changes: 56 additions & 0 deletions gts-cli/tests/server_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,54 @@ async fn test_add_schemas_requires_an_array_of_canonical_schemas() {
);
}

#[tokio::test]
async fn test_add_schemas_validate_commits_only_valid_entries() {
let app = create_test_router(create_test_ops(), 0);
let schema = |name: &str, properties: serde_json::Value| {
serde_json::json!({
"$id": format!("gts://gts.x.test6batchval._.{name}.v1~"),
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": properties
})
};
let dangling = schema(
"dangling",
serde_json::json!({"a": {"$ref": "gts://gts.x.test6batchval._.missing.v1~"}}),
);
let batch = serde_json::json!([
dangling,
schema(
"referrer",
serde_json::json!({"t": {"$ref": "gts://gts.x.test6batchval._.target.v1~"}}),
),
schema("target", serde_json::json!({"n": {"type": "string"}})),
]);

let (status, body) = post_json(&app, "/type-schemas?validate=true", &batch).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["ok"], serde_json::json!(false), "{body}");
assert_eq!(body["results"][0]["ok"], serde_json::json!(false));
assert_eq!(body["results"][1]["ok"], serde_json::json!(true), "{body}");
assert_eq!(body["results"][2]["ok"], serde_json::json!(true), "{body}");

let (_, body) = get_json(&app, "/entities/gts.x.test6batchval._.dangling.v1~").await;
assert_eq!(
body["ok"],
serde_json::json!(false),
"rejected entry must not commit"
);
let (_, body) = get_json(&app, "/entities/gts.x.test6batchval._.referrer.v1~").await;
assert_eq!(body["ok"], serde_json::json!(true));

let (_, body) = post_json(&app, "/type-schemas", &serde_json::json!([dangling])).await;
assert_eq!(
body["ok"],
serde_json::json!(true),
"without validate a forward reference registers"
);
}

#[tokio::test]
async fn test_add_schemas_refuses_what_add_entity_refuses() {
let app = create_test_router(create_test_ops(), 0);
Expand Down Expand Up @@ -833,6 +881,14 @@ async fn test_unknown_gts_ref_validation_mode_is_rejected() {
"type": "object"
}),
),
(
"/type-schemas?gts-ref-validation=unknown",
serde_json::json!([{
"$id": format!("gts://{type_id}"),
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object"
}]),
),
] {
let (status, body) = post_json(&app, uri, &body).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{uri}");
Expand Down
157 changes: 153 additions & 4 deletions gts/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,10 +689,60 @@ impl GtsOps {
/// give the same verdict on the same document. Entries are independent: a
/// rejected one leaves the others registered.
pub fn add_schemas(&mut self, schemas: &[Value]) -> GtsAddSchemasResult {
let results: Vec<GtsAddSchemaResult> = schemas
.iter()
.map(|schema| self.add_type_schema(schema))
.collect();
self.add_schemas_with(schemas, false, GtsRefValidation::default())
}

/// [`Self::add_schemas`], optionally with full validation of every entry
/// (spec v0.14.4 §9.3 Batch Type Schema Registration).
///
/// With `validate`, the whole batch is staged first, so an entry may
/// reference or derive from one that comes later in the array. Staged
/// entries are then validated until no new failure appears: each rejected
/// entry is unstaged and the survivors are checked again, so nothing is
/// committed on top of a rejected sibling. The batch may partly succeed;
/// a rejected entry is never committed, and an id already stored with the
/// same content stays stored. `&mut self` keeps staged entries hidden
/// from concurrent readers until the call returns.
pub fn add_schemas_with(
&mut self,
schemas: &[Value],
validate: bool,
refs: GtsRefValidation,
) -> GtsAddSchemasResult {
let mut results = Vec::with_capacity(schemas.len());
// Staged entries awaiting validation: result index, id, whether this
// call inserted it (and so must remove it on rejection).
let mut staged = Vec::new();
for schema in schemas {
let existed = validate
&& GtsStore::declared_type_id(schema)
.is_ok_and(|type_id| self.store.get(&type_id).is_some());
let result = self.add_type_schema(schema);
if validate && let (true, Some(type_id)) = (result.ok, &result.type_id) {
staged.push((results.len(), type_id.clone(), !existed));
}
results.push(result);
}

loop {
let before = staged.len();
staged.retain(|(index, type_id, inserted)| {
let Err(e) = self.store.validate_schema_with(type_id, refs) else {
return true;
};
if *inserted {
self.store.unregister(type_id);
}
let result: &mut GtsAddSchemaResult = &mut results[*index];
result.ok = false;
result.error = format!("Schema validation failed: {e}");
false
});
if staged.len() == before {
break;
}
}

let ok = results.iter().all(|r| r.ok);
GtsAddSchemasResult { ok, results }
}
Expand Down Expand Up @@ -3535,6 +3585,105 @@ mod tests {
);
}

fn batch_schema(type_id: &str, properties: &Value) -> Value {
json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": format!("gts://{type_id}"),
"type": "object",
"properties": properties
})
}

#[test]
fn test_add_schemas_with_validate_resolves_later_entries() {
let mut ops = GtsOps::new(None, None, 0);
let referrer = batch_schema(
"gts.x.batch._.referrer.v1~",
&json!({"child": {"$ref": "gts://gts.x.batch._.target.v1~"}}),
);
let derived = json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "gts://gts.x.batch._.target.v1~x.batch._.derived.v1~",
"type": "object",
"allOf": [{"$ref": "gts://gts.x.batch._.target.v1~"}]
});
let target = batch_schema(
"gts.x.batch._.target.v1~",
&json!({"n": {"type": "string"}}),
);

let result = ops.add_schemas_with(
&[referrer, derived, target],
true,
GtsRefValidation::default(),
);
assert!(result.ok, "{:?}", result.results);
assert!(ops.get_entity("gts.x.batch._.referrer.v1~").ok);
assert!(
ops.get_entity("gts.x.batch._.target.v1~x.batch._.derived.v1~")
.ok
);
}

#[test]
fn test_add_schemas_with_validate_commits_only_valid_entries() {
let mut ops = GtsOps::new(None, None, 0);
let valid = batch_schema("gts.x.batch._.valid.v1~", &json!({"n": {"type": "string"}}));
let invalid = batch_schema(
"gts.x.batch._.invalid.v1~",
&json!({"a": {"$ref": "gts://gts.x.batch._.missing.v1~"}}),
);

let result =
ops.add_schemas_with(&[valid, invalid.clone()], true, GtsRefValidation::default());
assert!(!result.ok);
assert!(result.results[0].ok, "{}", result.results[0].error);
assert!(!result.results[1].ok);
assert!(ops.get_entity("gts.x.batch._.valid.v1~").ok);
assert!(!ops.get_entity("gts.x.batch._.invalid.v1~").ok);

// Without `validate` the same forward reference registers.
assert!(ops.add_schemas(&[invalid]).ok);
}

#[test]
fn test_add_schemas_with_validate_rejects_dependents_of_rejected_entries() {
let mut ops = GtsOps::new(None, None, 0);
// `b` comes first, so it passes while `a` is still staged and must be
// re-checked once `a` is rejected.
let b = batch_schema(
"gts.x.batch._.b.v1~",
&json!({"x": {"type": "string", "x-gts-ref": "gts.x.batch._.a.v1~"}}),
);
let a = batch_schema(
"gts.x.batch._.a.v1~",
&json!({"r": {"type": "string", "x-gts-ref": "gts.x.batch._.missing.v1~"}}),
);

let result = ops.add_schemas_with(&[b, a], true, GtsRefValidation::AnyPresent);
assert!(!result.results[0].ok, "b depends on the rejected a");
assert!(!result.results[1].ok);
assert!(!ops.get_entity("gts.x.batch._.a.v1~").ok);
assert!(!ops.get_entity("gts.x.batch._.b.v1~").ok);
}

#[test]
fn test_add_schemas_with_validate_keeps_previously_stored_entries() {
let mut ops = GtsOps::new(None, None, 0);
let stored = batch_schema(
"gts.x.batch._.stored.v1~",
&json!({"a": {"$ref": "gts://gts.x.batch._.missing.v1~"}}),
);
assert!(ops.add_schemas(std::slice::from_ref(&stored)).ok);

let result = ops.add_schemas_with(&[stored], true, GtsRefValidation::default());
assert!(!result.ok, "the stored entry is still invalid");
assert!(
ops.get_entity("gts.x.batch._.stored.v1~").ok,
"a rejected resubmission must not remove what was already stored"
);
}

#[test]
fn test_add_entity_rolls_back_an_instance_that_fails_validation() {
let mut ops = GtsOps::new(None, None, 0);
Expand Down
53 changes: 48 additions & 5 deletions gts/src/schema_traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ impl EffectiveTraits {
check_unresolved: bool,
entity_exists: Option<crate::x_gts_ref::ReferenceExists>,
) -> Result<(), Vec<String>> {
validate_trait_schema_integrity(&self.resolved_trait_schemas)?;
let dialect = self.schema.get("$schema").and_then(Value::as_str);
validate_trait_schema_integrity(&self.resolved_trait_schemas, dialect)?;
validate_trait_schema_compatibility(&self.resolved_trait_schemas)?;

if !self.has_schema() {
Expand Down Expand Up @@ -269,10 +270,17 @@ pub fn validate_traits_chain(chain_schemas: &[(String, Value)]) -> Result<(), Ve
/// preserving indexed error messages for malformed inputs. Run first by
/// [`EffectiveTraits::validate`].
///
/// A fragment without its own `$schema` is compiled under `dialect`, the host
/// document's, so a Draft-07 tuple `items: [...]` is not judged by the 2020-12
/// metaschema. `None` leaves the validator's default draft.
///
/// # Errors
/// Returns `Vec<String>` of error messages if any collected trait schema is not
/// a JSON Schema object or boolean subschema.
fn validate_trait_schema_integrity(resolved_trait_schemas: &[Value]) -> Result<(), Vec<String>> {
fn validate_trait_schema_integrity(
resolved_trait_schemas: &[Value],
dialect: Option<&str>,
) -> Result<(), Vec<String>> {
// Each x-gts-traits-schema is a JSON Schema subschema. Accepted forms are
// an object subschema, `true`, or `false`. Validate JSON Schema integrity
// only for object-form subschemas; the boolean forms have well-defined
Expand All @@ -291,8 +299,18 @@ fn validate_trait_schema_integrity(resolved_trait_schemas: &[Value]) -> Result<(
for (i, ts) in resolved_trait_schemas.iter().enumerate() {
match ts {
Value::Bool(_) => {}
Value::Object(_) => {
if let Err(e) = crate::json_schema::validator_for(ts) {
Value::Object(fragment) => {
let pinned;
let schema = match dialect {
Some(dialect) if !fragment.contains_key("$schema") => {
let mut fragment = fragment.clone();
fragment.insert("$schema".to_owned(), Value::String(dialect.to_owned()));
pinned = Value::Object(fragment);
&pinned
}
_ => ts,
};
if let Err(e) = crate::json_schema::validator_for(schema) {
return Err(vec![format!(
"x-gts-traits-schema[{i}] is not a valid JSON Schema: {e}"
)]);
Expand Down Expand Up @@ -1036,7 +1054,7 @@ mod tests {
// A resolved trait schema that is neither an object subschema nor a
// boolean (here, an array) must hit the dedicated error arm.
let schemas = vec![json!([1, 2])];
let err = validate_trait_schema_integrity(&schemas).unwrap_err();
let err = validate_trait_schema_integrity(&schemas, None).unwrap_err();
assert!(
err.iter()
.any(|m| m.contains("must be an object subschema or a boolean")),
Expand All @@ -1059,6 +1077,31 @@ mod tests {
assert!(inlined.get("$ref").is_none());
}

#[test]
fn test_trait_schema_integrity_uses_host_dialect() {
// Tuple-form `items` is legal Draft-07 but not 2020-12, so the fragment
// must be judged by the host document's metaschema.
let host = |dialect: &str| {
vec![(
"base~".to_owned(),
json!({"$schema": dialect,
"type": "object",
"x-gts-traits-schema": {
"type": "object",
"properties": {"pair": {"type": "array", "items": [{"type": "string"}]}}
}
}),
)]
};
assert!(validate_traits_chain(&host("http://json-schema.org/draft-07/schema#")).is_ok());
let err = validate_traits_chain(&host("https://json-schema.org/draft/2020-12/schema"))
.unwrap_err();
assert!(
err.iter().any(|m| m.contains("is not a valid JSON Schema")),
"2020-12 must reject tuple items: {err:?}"
);
}

#[test]
fn test_no_traits_schema_passes() {
let chain = vec![(
Expand Down
Loading