use serde_json::Value;
use std::collections::BTreeSet;
use crate::gts::{GTS_ID_URI_PREFIX, GtsTypeId};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidRefReason {
#[error("must be a local ref (starting with '#') or a GTS URI (starting with 'gts://')")]
NotGtsUri,
#[error("must reference a GTS type id (a valid identifier ending with '~'), got '{0}'")]
InvalidTypeId(String),
#[error(
"has an unsupported fragment '#{0}'; only an empty fragment or a '/'-prefixed JSON \
Pointer is allowed"
)]
UnsupportedFragment(String),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ExtractRefsError {
#[error("at '{path}': '{ref_uri}' {reason}")]
InvalidRef {
path: String,
ref_uri: String,
reason: InvalidRefReason,
},
#[error("schema nests deeper than the maximum scan depth of {0}")]
TooDeep(usize),
}
enum RefKind<'a> {
Local,
External { id: &'a str },
}
fn classify_ref(ref_uri: &str) -> Result<RefKind<'_>, InvalidRefReason> {
if ref_uri.starts_with('#') {
return Ok(RefKind::Local);
}
let Some(rest) = ref_uri.strip_prefix(GTS_ID_URI_PREFIX) else {
return Err(InvalidRefReason::NotGtsUri);
};
let (id, fragment) = match rest.split_once('#') {
Some((id, frag)) => (id, Some(frag)),
None => (rest, None),
};
if GtsTypeId::try_new(id).is_err() {
return Err(InvalidRefReason::InvalidTypeId(id.to_owned()));
}
if let Some(frag) = fragment
&& !frag.is_empty()
&& !frag.starts_with('/')
{
return Err(InvalidRefReason::UnsupportedFragment(frag.to_owned()));
}
Ok(RefKind::External { id })
}
pub fn extract_gts_refs(schema: &Value) -> Result<BTreeSet<String>, ExtractRefsError> {
let mut refs = BTreeSet::new();
collect_gts_refs(schema, "", 0, &mut refs)?;
Ok(refs)
}
fn collect_gts_refs(
value: &Value,
path: &str,
depth: usize,
out: &mut BTreeSet<String>,
) -> Result<(), ExtractRefsError> {
const MAX_REF_SCAN_DEPTH: usize = 64;
if depth > MAX_REF_SCAN_DEPTH {
return Err(ExtractRefsError::TooDeep(MAX_REF_SCAN_DEPTH));
}
match value {
Value::Object(map) => {
if let Some(Value::String(ref_uri)) = map.get("$ref") {
let ref_path = if path.is_empty() {
"$ref".to_owned()
} else {
format!("{path}.$ref")
};
match classify_ref(ref_uri) {
Ok(RefKind::Local) => {}
Ok(RefKind::External { id }) => {
out.insert(id.to_owned());
}
Err(reason) => {
return Err(ExtractRefsError::InvalidRef {
path: ref_path,
ref_uri: ref_uri.clone(),
reason,
});
}
}
}
for (key, v) in map {
if key == "$ref" {
continue; }
if matches!(key.as_str(), "const" | "default" | "examples" | "enum") {
continue;
}
let nested = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
collect_gts_refs(v, &nested, depth + 1, out)?;
}
}
Value::Array(items) => {
for (idx, v) in items.iter().enumerate() {
let nested = format!("{path}[{idx}]");
collect_gts_refs(v, &nested, depth + 1, out)?;
}
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_gts_refs_body_traits_and_normalization() {
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"a": {"$ref": "gts://gts.x.dep.ns.a.v1~"},
"b": {"$ref": "gts://gts.x.dep.ns.b.v1~"},
"c": {"$ref": "gts://gts.x.dep.ns.c.v1~#/properties/inner"},
"d": {"$ref": "#/$defs/Local"},
"e": {"type": "string", "x-gts-ref": "gts.x.notdep.ns.e.v1~"},
"f": {"$ref": "gts://gts.x.dep.ns.a.v1~"}
},
"allOf": [{"$ref": "gts://gts.x.dep.ns.allof.v1~"}],
"x-gts-traits-schema": {
"type": "object",
"properties": {"t": {"$ref": "gts://gts.x.dep.ns.trait.v1~"}}
}
});
let refs = extract_gts_refs(&schema).unwrap();
let expected: BTreeSet<String> = [
"gts.x.dep.ns.a.v1~",
"gts.x.dep.ns.b.v1~",
"gts.x.dep.ns.c.v1~",
"gts.x.dep.ns.allof.v1~",
"gts.x.dep.ns.trait.v1~",
]
.iter()
.map(|s| (*s).to_owned())
.collect();
assert_eq!(refs, expected);
}
#[test]
fn extract_gts_refs_none() {
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {"id": {"type": "string"}},
"x-gts-traits-schema": {"type": "object"}
});
assert!(extract_gts_refs(&schema).unwrap().is_empty());
}
#[test]
fn extract_gts_refs_ignores_data_valued_keywords() {
let schema = json!({
"type": "object",
"properties": {
"a": {"const": {"$ref": "not-a-schema-ref"}},
"b": {"default": {"nested": {"$ref": "also-not-a-ref"}}},
"c": {"enum": [{"$ref": "still-data"}]},
"d": {"examples": [{"$ref": "example-data"}]},
"e": {"$ref": "gts://gts.x.dep.ns.real.v1~"}
}
});
let refs = extract_gts_refs(&schema).unwrap();
let expected: BTreeSet<String> = ["gts.x.dep.ns.real.v1~".to_owned()].into_iter().collect();
assert_eq!(refs, expected);
}
#[test]
fn extract_gts_refs_rejects_bare_id() {
let bare_ref = json!({
"type": "object",
"properties": {"a": {"$ref": "gts.x.dep.ns.a.v1~"}}
});
assert!(matches!(
extract_gts_refs(&bare_ref),
Err(ExtractRefsError::InvalidRef {
reason: InvalidRefReason::NotGtsUri,
..
})
));
}
#[test]
fn extract_gts_refs_rejects_invalid() {
let instance_ref = json!({
"type": "object",
"properties": {"a": {"$ref": "gts://gts.x.dep.ns.a.v1"}}
});
assert!(matches!(
extract_gts_refs(&instance_ref),
Err(ExtractRefsError::InvalidRef {
reason: InvalidRefReason::InvalidTypeId(_),
..
})
));
let garbage_ref = json!({
"type": "object",
"properties": {"a": {"$ref": "gts://not a gts id"}}
});
assert!(matches!(
extract_gts_refs(&garbage_ref),
Err(ExtractRefsError::InvalidRef {
reason: InvalidRefReason::InvalidTypeId(_),
..
})
));
}
#[test]
fn extract_gts_refs_reports_path() {
let schema = json!({
"properties": {"order": {"$ref": "invalid-ref"}}
});
let err = extract_gts_refs(&schema).unwrap_err();
assert!(
err.to_string().contains("properties.order.$ref"),
"error should report the path, got: {err}"
);
}
#[test]
fn extract_gts_refs_rejects_unsupported_fragment() {
let schema = json!({"$ref": "gts://gts.x.dep.ns.a.v1~#bad"});
assert!(matches!(
extract_gts_refs(&schema),
Err(ExtractRefsError::InvalidRef {
reason: InvalidRefReason::UnsupportedFragment(_),
..
})
));
}
#[test]
fn extract_gts_refs_rejects_too_deep() {
let mut schema = json!({"type": "object"});
for _ in 0..70 {
schema = json!({"properties": {"nested": schema}});
}
assert_eq!(
extract_gts_refs(&schema),
Err(ExtractRefsError::TooDeep(64))
);
}
#[test]
fn extract_gts_refs_found_in_arrays() {
let schema = json!({
"oneOf": [
{"$ref": "gts://gts.x.dep.ns.one.v1~"},
{"$ref": "gts://gts.x.dep.ns.two.v1~"}
]
});
let refs = extract_gts_refs(&schema).unwrap();
let expected: BTreeSet<String> = ["gts.x.dep.ns.one.v1~", "gts.x.dep.ns.two.v1~"]
.iter()
.map(|s| (*s).to_owned())
.collect();
assert_eq!(refs, expected);
}
#[test]
fn extract_gts_refs_empty_fragment_normalizes_to_id() {
let schema = json!({"$ref": "gts://gts.x.dep.ns.a.v1~#"});
let refs = extract_gts_refs(&schema).unwrap();
assert_eq!(refs, BTreeSet::from(["gts.x.dep.ns.a.v1~".to_owned()]));
}
#[test]
fn extract_gts_refs_internal_pointer_only_excluded() {
let schema = json!({
"type": "object",
"properties": {"a": {"$ref": "#/$defs/Local"}},
"$defs": {"Local": {"type": "string"}}
});
assert!(extract_gts_refs(&schema).unwrap().is_empty());
}
}