use std::path::Path;
use serde_json::Value;
use crate::error::ResolveError;
#[cfg(feature = "remote")]
use std::time::Duration;
#[cfg(feature = "remote")]
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
pub fn load_schema(path: &Path) -> Result<Value, ResolveError> {
if !path.exists() {
return Err(ResolveError::FileNotFound {
path: path.to_path_buf(),
});
}
let content = std::fs::read_to_string(path).map_err(|source| ResolveError::ReadError {
path: path.to_path_buf(),
source,
})?;
serde_json::from_str(&content).map_err(|source| ResolveError::InvalidJson { source })
}
pub fn load_schema_str(content: &str) -> Result<Value, ResolveError> {
serde_json::from_str(content).map_err(|source| ResolveError::InvalidJson { source })
}
#[cfg(feature = "remote")]
pub fn load_schema_url(url: &str) -> Result<Value, ResolveError> {
let client = reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.build()
.map_err(|source| ResolveError::NetworkError {
url: url.to_string(),
source,
})?;
let response = client
.get(url)
.send()
.map_err(|source| ResolveError::NetworkError {
url: url.to_string(),
source,
})?;
let response = response
.error_for_status()
.map_err(|source| ResolveError::NetworkError {
url: url.to_string(),
source,
})?;
response
.json()
.map_err(|source| ResolveError::NetworkError {
url: url.to_string(),
source,
})
}
pub fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
pub fn navigate_fragment(schema: &Value, fragment: &str) -> Result<Value, ResolveError> {
let path = fragment.trim_start_matches('#').trim_start_matches('/');
if path.is_empty() {
return Ok(schema.clone());
}
let mut current = schema;
for part in path.split('/') {
let key = part.replace("~1", "/").replace("~0", "~");
current = current.get(&key).ok_or_else(|| ResolveError::BundleError {
message: format!("fragment not found: {}", fragment),
})?;
}
Ok(current.clone())
}
pub fn bundle_refs(schema: &mut Value, base_dir: &Path) -> Result<(), ResolveError> {
bundle_refs_inner(
schema,
base_dir,
None,
None,
None,
&mut std::collections::HashSet::new(),
)
}
pub fn bundle_refs_with_url_mapping(
schema: &mut Value,
base_dir: &Path,
local_base: &Path,
remote_base: &str,
) -> Result<(), ResolveError> {
bundle_refs_inner(
schema,
base_dir,
None,
Some(local_base),
Some(remote_base),
&mut std::collections::HashSet::new(),
)
}
fn bundle_refs_inner(
schema: &mut Value,
base_dir: &Path,
file_root: Option<&Value>, url_local_base: Option<&Path>,
url_remote_base: Option<&str>,
visited: &mut std::collections::HashSet<String>,
) -> Result<(), ResolveError> {
match schema {
Value::Object(obj) => {
if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) {
if ref_val.starts_with('#') {
if ref_val == "#" {
} else if let Some(root) = file_root {
let mut target = navigate_fragment(root, ref_val)?;
bundle_refs_inner(
&mut target,
base_dir,
file_root,
url_local_base,
url_remote_base,
visited,
)?;
obj.remove("$ref");
if let Value::Object(ref_obj) = target {
for (k, v) in ref_obj {
obj.entry(k).or_insert(v);
}
}
return Ok(());
}
} else {
let (file_part, fragment) = match ref_val.find('#') {
Some(idx) => (&ref_val[..idx], Some(&ref_val[idx..])),
None => (ref_val, None),
};
let ref_path =
resolve_ref_to_path(file_part, base_dir, url_local_base, url_remote_base);
let canonical = ref_path.canonicalize().unwrap_or(ref_path.clone());
let visit_key = format!("{}|{}", canonical.display(), fragment.unwrap_or(""));
if visited.contains(&visit_key) {
return Err(ResolveError::BundleError {
message: format!("circular reference detected: {}", ref_val),
});
}
let loaded = load_schema(&ref_path)?;
let mut target = if let Some(frag) = fragment {
navigate_fragment(&loaded, frag)?
} else {
loaded.clone()
};
visited.insert(visit_key.clone());
let ref_dir = ref_path.parent().unwrap_or(base_dir);
bundle_refs_inner(
&mut target,
ref_dir,
Some(&loaded),
url_local_base,
url_remote_base,
visited,
)?;
visited.remove(&visit_key);
obj.remove("$ref");
if let Value::Object(ref_obj) = target {
for (k, v) in ref_obj {
obj.entry(k).or_insert(v);
}
}
return Ok(());
}
}
for value in obj.values_mut() {
bundle_refs_inner(
value,
base_dir,
file_root,
url_local_base,
url_remote_base,
visited,
)?;
}
}
Value::Array(arr) => {
for item in arr {
bundle_refs_inner(
item,
base_dir,
file_root,
url_local_base,
url_remote_base,
visited,
)?;
}
}
_ => {}
}
Ok(())
}
fn resolve_ref_to_path(
ref_val: &str,
base_dir: &Path,
url_local_base: Option<&Path>,
url_remote_base: Option<&str>,
) -> std::path::PathBuf {
if let (Some(local_base), Some(remote_base)) = (url_local_base, url_remote_base) {
if let Some(remainder) = ref_val.strip_prefix(remote_base) {
return local_base.join(remainder.trim_start_matches('/'));
}
}
base_dir.join(ref_val)
}
pub fn load_schema_auto(source: &str) -> Result<Value, ResolveError> {
if is_url(source) {
#[cfg(feature = "remote")]
{
load_schema_url(source)
}
#[cfg(not(feature = "remote"))]
{
Err(ResolveError::FileNotFound {
path: std::path::PathBuf::from(source),
})
}
} else {
load_schema(Path::new(source))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn load_schema_valid_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"type": "object"}}"#).unwrap();
let schema = load_schema(file.path()).unwrap();
assert_eq!(schema["type"], "object");
}
#[test]
fn load_schema_file_not_found() {
let result = load_schema(Path::new("/nonexistent/path.json"));
assert!(matches!(result, Err(ResolveError::FileNotFound { .. })));
}
#[test]
fn load_schema_invalid_json() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "not valid json").unwrap();
let result = load_schema(file.path());
assert!(matches!(result, Err(ResolveError::InvalidJson { .. })));
}
#[test]
fn load_schema_str_valid() {
let schema = load_schema_str(r#"{"type": "object"}"#).unwrap();
assert_eq!(schema["type"], "object");
}
#[test]
fn load_schema_str_invalid() {
let result = load_schema_str("not json");
assert!(matches!(result, Err(ResolveError::InvalidJson { .. })));
}
#[test]
fn is_url_https() {
assert!(is_url("https://example.com/schema.json"));
}
#[test]
fn is_url_http() {
assert!(is_url("http://example.com/schema.json"));
}
#[test]
fn is_url_file_path() {
assert!(!is_url("/path/to/schema.json"));
assert!(!is_url("./schema.json"));
assert!(!is_url("schema.json"));
}
#[test]
fn load_schema_auto_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"{{"type": "string"}}"#).unwrap();
let schema = load_schema_auto(file.path().to_str().unwrap()).unwrap();
assert_eq!(schema["type"], "string");
}
#[test]
fn resolve_ref_to_path_with_url_mapping() {
let base_dir = Path::new("/some/dir");
let local_base = Path::new("/local/schemas");
let remote_base = "https://ucp.dev/draft";
let path = resolve_ref_to_path(
"https://ucp.dev/draft/schemas/ucp.json",
base_dir,
Some(local_base),
Some(remote_base),
);
assert_eq!(path, Path::new("/local/schemas/schemas/ucp.json"));
}
#[test]
fn resolve_ref_to_path_url_not_matching_remote() {
let base_dir = Path::new("/some/dir");
let local_base = Path::new("/local/schemas");
let remote_base = "https://ucp.dev/draft";
let path = resolve_ref_to_path(
"https://other.com/schemas/foo.json",
base_dir,
Some(local_base),
Some(remote_base),
);
assert_eq!(
path,
Path::new("/some/dir/https://other.com/schemas/foo.json")
);
}
#[test]
fn resolve_ref_to_path_relative_ref() {
let base_dir = Path::new("/some/dir");
let path = resolve_ref_to_path("types/buyer.json", base_dir, None, None);
assert_eq!(path, Path::new("/some/dir/types/buyer.json"));
}
#[test]
fn resolve_ref_to_path_strips_leading_slash() {
let base_dir = Path::new("/some/dir");
let local_base = Path::new("/local");
let remote_base = "https://ucp.dev/draft";
let path = resolve_ref_to_path(
"https://ucp.dev/draft/schemas/foo.json",
base_dir,
Some(local_base),
Some(remote_base),
);
assert_eq!(path, Path::new("/local/schemas/foo.json"));
}
#[cfg(feature = "remote")]
mod remote {
use super::*;
#[test]
fn load_schema_url_valid() {
let result = load_schema_url("https://httpbin.org/json");
assert!(result.is_ok());
let schema = result.unwrap();
assert!(schema.get("slideshow").is_some());
}
#[test]
fn load_schema_url_404() {
let result = load_schema_url("https://httpbin.org/status/404");
assert!(matches!(result, Err(ResolveError::NetworkError { .. })));
}
#[test]
fn load_schema_url_invalid_host() {
let result =
load_schema_url("https://this-domain-does-not-exist-12345.invalid/schema.json");
assert!(matches!(result, Err(ResolveError::NetworkError { .. })));
}
#[test]
fn load_schema_auto_url() {
let result = load_schema_auto("https://httpbin.org/json");
assert!(result.is_ok());
}
}
}