use arc_swap::ArcSwap;
use chrono::{DateTime, Utc};
use sentry::ClientOptions;
use sentry::transports::DefaultTransportFactory;
use serde_json::Value;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process;
use std::sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
const FEATURE_SCHEMA_DEFS_JSON: &str = include_str!("feature-schema-defs.json");
const SCHEMA_FILE_NAME: &str = "schema.json";
const VALUES_FILE_NAME: &str = "values.json";
const POLLING_DELAY: u64 = 5;
#[cfg(not(test))]
const SENTRY_OPTIONS_DSN: &str =
"https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
#[cfg(test)]
const SENTRY_OPTIONS_DSN: &str = "";
static SENTRY_HUB: OnceLock<Arc<sentry::Hub>> = OnceLock::new();
fn get_sentry_hub() -> &'static Arc<sentry::Hub> {
SENTRY_HUB.get_or_init(|| {
let client = Arc::new(sentry::Client::from((
SENTRY_OPTIONS_DSN,
ClientOptions {
traces_sample_rate: 1.0,
transport: Some(Arc::new(DefaultTransportFactory)),
..Default::default()
},
)));
Arc::new(sentry::Hub::new(
Some(client),
Arc::new(sentry::Scope::default()),
))
})
}
pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
fn should_suppress_missing_dir_errors() -> bool {
std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub fn resolve_options_dir() -> PathBuf {
if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
return PathBuf::from(dir);
}
let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
if prod_path.exists() {
return prod_path;
}
PathBuf::from(LOCAL_OPTIONS_DIR)
}
pub type ValidationResult<T> = Result<T, ValidationError>;
pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("Schema error in {file}: {message}")]
SchemaError { file: PathBuf, message: String },
#[error("Value error for {namespace}: {errors}")]
ValueError { namespace: String, errors: String },
#[error("Unknown namespace: {0}")]
UnknownNamespace(String),
#[error("Unknown option '{key}' in namespace '{namespace}'")]
UnknownOption { namespace: String, key: String },
#[error("Internal error: {0}")]
InternalError(String),
#[error("Failed to read file: {0}")]
FileRead(#[from] std::io::Error),
#[error("Failed to parse JSON: {0}")]
JSONParse(#[from] serde_json::Error),
#[error("{} validation error(s)", .0.len())]
ValidationErrors(Vec<ValidationError>),
#[error("Invalid {label} '{name}': {reason}")]
InvalidName {
label: String,
name: String,
reason: String,
},
}
pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
if let Some(c) = name
.chars()
.find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
{
return Err(ValidationError::InvalidName {
label: label.to_string(),
name: name.to_string(),
reason: format!(
"character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
c
),
});
}
if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
|| !name.ends_with(|c: char| c.is_ascii_alphanumeric())
{
return Err(ValidationError::InvalidName {
label: label.to_string(),
name: name.to_string(),
reason: "must start and end with alphanumeric".to_string(),
});
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct OptionMetadata {
pub option_type: String,
pub property_schema: Value,
pub default: Value,
}
pub struct NamespaceSchema {
pub namespace: String,
pub options: HashMap<String, OptionMetadata>,
all_keys: HashSet<String>,
validator: jsonschema::Validator,
}
impl NamespaceSchema {
pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
let output = self.validator.evaluate(values);
if output.flag().valid {
Ok(())
} else {
let errors: Vec<String> = output
.iter_errors()
.map(|e| {
format!(
"\n\t{} {}",
e.instance_location.as_str().trim_start_matches("/"),
e.error
)
})
.collect();
Err(ValidationError::ValueError {
namespace: self.namespace.clone(),
errors: errors.join(""),
})
}
}
pub fn get_default(&self, key: &str) -> Option<&Value> {
self.options.get(key).map(|meta| &meta.default)
}
pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
if !self.options.contains_key(key) {
return Err(ValidationError::UnknownOption {
namespace: self.namespace.clone(),
key: key.to_string(),
});
}
let test_obj = json!({ key: value });
self.validate_values(&test_obj)
}
}
pub struct SchemaRegistry {
schemas: HashMap<String, Arc<NamespaceSchema>>,
}
impl SchemaRegistry {
pub fn new() -> Self {
Self {
schemas: HashMap::new(),
}
}
pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
let namespace_validator = Self::compile_namespace_validator()?;
let mut schemas_map = HashMap::new();
for entry in fs::read_dir(schemas_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let namespace =
entry
.file_name()
.into_string()
.map_err(|_| ValidationError::SchemaError {
file: entry.path(),
message: "Directory name contains invalid UTF-8".to_string(),
})?;
validate_k8s_name_component(&namespace, "namespace name")?;
let schema_file = entry.path().join(SCHEMA_FILE_NAME);
let file = fs::File::open(&schema_file)?;
let schema_data: Value = serde_json::from_reader(file)?;
Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
schemas_map.insert(namespace, schema);
}
Ok(Self {
schemas: schemas_map,
})
}
pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
let namespace_validator = Self::compile_namespace_validator()?;
let schema_file = Path::new("<embedded>");
let mut schemas_map = HashMap::new();
for (namespace, json) in schemas {
validate_k8s_name_component(namespace, "namespace name")?;
let schema_data: Value =
serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
file: schema_file.to_path_buf(),
message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
})?;
Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
schemas_map.insert(namespace.to_string(), schema);
}
Ok(Self {
schemas: schemas_map,
})
}
pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
let schema = self
.schemas
.get(namespace)
.ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
schema.validate_values(values)
}
fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
let namespace_schema_value: Value =
serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
})?;
jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
})
}
fn validate_with_namespace_schema(
schema_data: &Value,
path: &Path,
namespace_validator: &jsonschema::Validator,
) -> ValidationResult<()> {
let output = namespace_validator.evaluate(schema_data);
if output.flag().valid {
Ok(())
} else {
let errors: Vec<String> = output
.iter_errors()
.map(|e| format!("Error: {}", e.error))
.collect();
Err(ValidationError::SchemaError {
file: path.to_path_buf(),
message: format!("Schema validation failed:\n{}", errors.join("\n")),
})
}
}
fn validate_default_type(
property_name: &str,
property_schema: &Value,
default_value: &Value,
path: &Path,
) -> ValidationResult<()> {
jsonschema::validate(property_schema, default_value).map_err(|e| {
ValidationError::SchemaError {
file: path.to_path_buf(),
message: format!(
"Property '{}': default value does not match schema: {}",
property_name, e
),
}
})?;
Ok(())
}
fn inject_object_constraints(schema: &mut Value) {
if let Some(obj) = schema.as_object_mut() {
if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
let required: Vec<Value> = props
.iter()
.filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
.map(|(k, _)| Value::String(k.clone()))
.collect();
obj.insert("required".to_string(), Value::Array(required));
}
if !obj.contains_key("additionalProperties") {
obj.insert("additionalProperties".to_string(), json!(false));
}
}
}
fn parse_schema(
mut schema: Value,
namespace: &str,
path: &Path,
) -> ValidationResult<Arc<NamespaceSchema>> {
if let Some(obj) = schema.as_object_mut() {
obj.insert("additionalProperties".to_string(), json!(false));
}
if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
for prop_value in properties.values_mut() {
let prop_type = prop_value
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
if prop_type == "object" {
Self::inject_object_constraints(prop_value);
} else if prop_type == "array"
&& let Some(items) = prop_value.get_mut("items")
{
let items_type = items.get("type").and_then(|t| t.as_str()).unwrap_or("");
if items_type == "object" {
Self::inject_object_constraints(items);
}
}
}
}
let mut options = HashMap::new();
let mut all_keys = HashSet::new();
let mut has_feature_keys = false;
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
for (prop_name, prop_value) in properties {
all_keys.insert(prop_name.clone());
if prop_name.starts_with("feature.") {
has_feature_keys = true;
}
if let (Some(prop_type), Some(default_value)) = (
prop_value.get("type").and_then(|t| t.as_str()),
prop_value.get("default"),
) {
Self::validate_default_type(prop_name, prop_value, default_value, path)?;
options.insert(
prop_name.clone(),
OptionMetadata {
option_type: prop_type.to_string(),
property_schema: prop_value.clone(),
default: default_value.clone(),
},
);
}
}
}
if has_feature_keys {
let feature_defs: Value =
serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
ValidationError::InternalError(format!(
"Invalid feature-schema-defs JSON: {}",
e
))
})?;
if let Some(obj) = schema.as_object_mut() {
obj.insert("definitions".to_string(), feature_defs);
}
}
let validator =
jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
file: path.to_path_buf(),
message: format!("Failed to compile validator: {}", e),
})?;
Ok(Arc::new(NamespaceSchema {
namespace: namespace.to_string(),
options,
all_keys,
validator,
}))
}
pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
self.schemas.get(namespace)
}
pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
&self.schemas
}
pub fn load_values_json(
&self,
values_dir: &Path,
) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
let mut all_values = HashMap::new();
let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
for namespace in self.schemas.keys() {
let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
if !values_file.exists() {
continue;
}
let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
}
let values = parsed
.get("options")
.ok_or_else(|| ValidationError::ValueError {
namespace: namespace.clone(),
errors: "values.json must have an 'options' key".to_string(),
})?;
let values = self.strip_unknown_keys(namespace, values);
self.validate_values(namespace, &values)?;
if let Value::Object(obj) = values {
let ns_values: HashMap<String, Value> = obj.into_iter().collect();
all_values.insert(namespace.clone(), ns_values);
}
}
Ok((all_values, generated_at_by_namespace))
}
fn strip_unknown_keys(&self, namespace: &str, values: &Value) -> Value {
let schema = match self.schemas.get(namespace) {
Some(s) => s,
None => return values.clone(),
};
let obj = match values.as_object() {
Some(obj) => obj,
None => return values.clone(),
};
let unknown_keys: Vec<&String> = obj
.keys()
.filter(|k| !schema.all_keys.contains(*k))
.collect();
if unknown_keys.is_empty() {
return values.clone();
}
for key in &unknown_keys {
eprintln!(
"sentry-options: Ignoring unknown option '{}' in namespace '{}'. \
This is expected during deployments when values are updated before schemas.",
key, namespace
);
}
let filtered: serde_json::Map<String, Value> = obj
.iter()
.filter(|(k, _)| schema.all_keys.contains(*k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Value::Object(filtered)
}
}
impl Default for SchemaRegistry {
fn default() -> Self {
Self::new()
}
}
pub struct ValuesWatcher {
pid: u32,
stop_signal: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl ValuesWatcher {
pub fn new(
values_path: &Path,
registry: Arc<SchemaRegistry>,
values: Arc<ArcSwap<ValuesByNamespace>>,
) -> ValidationResult<Self> {
if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
eprintln!("Values directory does not exist: {}", values_path.display());
}
let stop_signal = Arc::new(AtomicBool::new(false));
let thread_signal = Arc::clone(&stop_signal);
let thread_path = values_path.to_path_buf();
let thread_registry = Arc::clone(®istry);
let thread_values = Arc::clone(&values);
let thread = thread::Builder::new()
.name("sentry-options-watcher".into())
.spawn(move || {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
Self::run(thread_signal, thread_path, thread_registry, thread_values);
}));
if let Err(e) = result {
eprintln!("Watcher thread panicked with: {:?}", e);
}
})?;
Ok(Self {
pid: process::id(),
stop_signal,
thread: Some(thread),
})
}
fn run(
stop_signal: Arc<AtomicBool>,
values_path: PathBuf,
registry: Arc<SchemaRegistry>,
values: Arc<ArcSwap<ValuesByNamespace>>,
) {
let mut last_mtime = Self::get_mtime(&values_path);
while !stop_signal.load(Ordering::Relaxed) {
if let Some(current_mtime) = Self::get_mtime(&values_path)
&& Some(current_mtime) != last_mtime
{
Self::reload_values(&values_path, ®istry, &values);
last_mtime = Some(current_mtime);
}
thread::sleep(Duration::from_secs(POLLING_DELAY));
}
}
fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
let mut latest_mtime = None;
let entries = match fs::read_dir(values_dir) {
Ok(e) => e,
Err(_) => return None,
};
for entry in entries.flatten() {
if !entry
.file_type()
.map(|file_type| file_type.is_dir())
.unwrap_or(false)
{
continue;
}
let values_file = entry.path().join(VALUES_FILE_NAME);
if let Ok(metadata) = fs::metadata(&values_file)
&& let Ok(mtime) = metadata.modified()
&& latest_mtime.is_none_or(|latest| mtime > latest)
{
latest_mtime = Some(mtime);
}
}
latest_mtime
}
pub(crate) fn reload_values(
values_path: &Path,
registry: &SchemaRegistry,
values: &Arc<ArcSwap<ValuesByNamespace>>,
) {
let reload_start = Instant::now();
match registry.load_values_json(values_path) {
Ok((new_values, generated_at_by_namespace)) => {
let namespaces: Vec<String> = new_values.keys().cloned().collect();
Self::update_values(values, new_values);
let reload_duration = reload_start.elapsed();
Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
}
Err(e) => {
eprintln!(
"Failed to reload values from {}: {}",
values_path.display(),
e
);
}
}
}
fn emit_reload_spans(
namespaces: &[String],
reload_duration: Duration,
generated_at_by_namespace: &HashMap<String, String>,
) {
let hub = get_sentry_hub();
let applied_at = Utc::now();
let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
for namespace in namespaces {
let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
tx_ctx.set_sampled(true);
let transaction = hub.start_transaction(tx_ctx);
transaction.set_data("reload_duration_ms", reload_duration_ms.into());
transaction.set_data("applied_at", applied_at.to_rfc3339().into());
if let Some(ts) = generated_at_by_namespace.get(namespace) {
transaction.set_data("generated_at", ts.as_str().into());
if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
.num_milliseconds() as f64
/ 1000.0;
transaction.set_data("propagation_delay_secs", delay_secs.into());
}
}
transaction.finish();
}
}
fn update_values(values: &Arc<ArcSwap<ValuesByNamespace>>, new_values: ValuesByNamespace) {
values.store(Arc::new(new_values));
}
pub fn stop(&mut self) {
self.stop_signal.store(true, Ordering::Relaxed);
if self.pid == process::id()
&& let Some(handle) = self.thread.take()
{
let _ = handle.join();
}
}
pub fn is_alive(&self) -> bool {
self.thread.as_ref().is_some_and(|t| !t.is_finished())
}
}
impl Drop for ValuesWatcher {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
let schema_dir = temp_dir.path().join(namespace);
fs::create_dir_all(&schema_dir).unwrap();
let schema_file = schema_dir.join("schema.json");
fs::write(&schema_file, schema_json).unwrap();
schema_file
}
fn create_test_schema_with_values(
temp_dir: &TempDir,
namespace: &str,
schema_json: &str,
values_json: &str,
) -> (PathBuf, PathBuf) {
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let schema_dir = schemas_dir.join(namespace);
fs::create_dir_all(&schema_dir).unwrap();
let schema_file = schema_dir.join("schema.json");
fs::write(&schema_file, schema_json).unwrap();
let ns_values_dir = values_dir.join(namespace);
fs::create_dir_all(&ns_values_dir).unwrap();
let values_file = ns_values_dir.join("values.json");
fs::write(&values_file, values_json).unwrap();
(schemas_dir, values_dir)
}
#[test]
fn test_validate_k8s_name_component_valid() {
assert!(validate_k8s_name_component("relay", "namespace").is_ok());
assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
}
#[test]
fn test_validate_k8s_name_component_rejects_uppercase() {
let result = validate_k8s_name_component("MyService", "namespace");
assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
}
#[test]
fn test_validate_k8s_name_component_rejects_underscore() {
let result = validate_k8s_name_component("my_service", "target");
assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
}
#[test]
fn test_validate_k8s_name_component_rejects_leading_hyphen() {
let result = validate_k8s_name_component("-service", "namespace");
assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
assert!(
result
.unwrap_err()
.to_string()
.contains("start and end with alphanumeric")
);
}
#[test]
fn test_validate_k8s_name_component_rejects_trailing_dot() {
let result = validate_k8s_name_component("service.", "namespace");
assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
assert!(
result
.unwrap_err()
.to_string()
.contains("start and end with alphanumeric")
);
}
#[test]
fn test_load_schema_valid() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"test-key": {
"type": "string",
"default": "test",
"description": "Test option"
}
}
}"#,
);
SchemaRegistry::from_directory(temp_dir.path()).unwrap();
}
#[test]
fn test_load_schema_missing_version() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"type": "object",
"properties": {}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
match result {
Err(ValidationError::SchemaError { message, .. }) => {
assert!(message.contains(
"Schema validation failed:
Error: \"version\" is a required property"
));
}
_ => panic!("Expected SchemaError for missing version"),
}
}
#[test]
fn test_unknown_namespace() {
let temp_dir = TempDir::new().unwrap();
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values("unknown", &json!({}));
assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
}
#[test]
fn test_multiple_namespaces() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"ns1",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"opt1": {
"type": "string",
"default": "default1",
"description": "First option"
}
}
}"#,
);
create_test_schema(
&temp_dir,
"ns2",
r#"{
"version": "2.0",
"type": "object",
"properties": {
"opt2": {
"type": "integer",
"default": 42,
"description": "Second option"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
assert!(registry.schemas.contains_key("ns1"));
assert!(registry.schemas.contains_key("ns2"));
}
#[test]
fn test_invalid_default_type() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"bad-default": {
"type": "integer",
"default": "not-a-number",
"description": "A bad default value"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
match result {
Err(ValidationError::SchemaError { message, .. }) => {
assert!(
message.contains("Property 'bad-default': default value does not match schema")
);
assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
}
_ => panic!("Expected SchemaError for invalid default type"),
}
}
#[test]
fn test_extra_properties() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"bad-property": {
"type": "integer",
"default": 0,
"description": "Test property",
"extra": "property"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
match result {
Err(ValidationError::SchemaError { message, .. }) => {
assert!(
message
.contains("Additional properties are not allowed ('extra' was unexpected)")
);
}
_ => panic!("Expected SchemaError for extra properties"),
}
}
#[test]
fn test_missing_description() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"missing-desc": {
"type": "string",
"default": "test"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
match result {
Err(ValidationError::SchemaError { message, .. }) => {
assert!(message.contains("\"description\" is a required property"));
}
_ => panic!("Expected SchemaError for missing description"),
}
}
#[test]
fn test_invalid_directory_structure() {
let temp_dir = TempDir::new().unwrap();
let schema_dir = temp_dir.path().join("missing-schema");
fs::create_dir_all(&schema_dir).unwrap();
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
match result {
Err(ValidationError::FileRead(..)) => {
}
_ => panic!("Expected FileRead error for missing schema.json"),
}
}
#[test]
fn test_get_default() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"string_opt": {
"type": "string",
"default": "hello",
"description": "A string option"
},
"int_opt": {
"type": "integer",
"default": 42,
"description": "An integer option"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let schema = registry.get("test").unwrap();
assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
assert_eq!(schema.get_default("unknown"), None);
}
#[test]
fn test_validate_values_valid() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable feature"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values("test", &json!({"enabled": true}));
assert!(result.is_ok());
}
#[test]
fn test_validate_values_invalid_type() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"count": {
"type": "integer",
"default": 0,
"description": "Count"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values("test", &json!({"count": "not a number"}));
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_validate_values_unknown_option() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"known_option": {
"type": "string",
"default": "default",
"description": "A known option"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values("test", &json!({"known_option": "value"}));
assert!(result.is_ok());
let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
assert!(result.is_err());
match result {
Err(ValidationError::ValueError { errors, .. }) => {
assert!(errors.contains("Additional properties are not allowed"));
}
_ => panic!("Expected ValueError for unknown option"),
}
}
#[test]
fn test_object_with_additional_properties() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"scopes": {
"type": "object",
"additionalProperties": {"type": "string"},
"default": {},
"description": "A dynamic string-to-string map"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
assert!(
registry
.validate_values("test", &json!({"scopes": {}}))
.is_ok()
);
assert!(
registry
.validate_values(
"test",
&json!({"scopes": {"read": "true", "write": "false"}})
)
.is_ok()
);
assert!(matches!(
registry.validate_values("test", &json!({"scopes": {"read": 42}})),
Err(ValidationError::ValueError { .. })
));
}
#[test]
fn test_object_without_additional_properties_still_rejects_unknown_keys() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"}
},
"default": {"host": "localhost"},
"description": "Server config"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values("test", &json!({"config": {"host": "example.com"}}));
assert!(result.is_ok());
let result = registry.validate_values(
"test",
&json!({"config": {"host": "example.com", "unknown": "x"}}),
);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_object_with_fixed_properties_and_additional_properties_enforces_required() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
"additionalProperties": {"type": "string"},
"default": {"name": "default"},
"description": "Config with fixed and dynamic keys"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result =
registry.validate_values("test", &json!({"config": {"name": "x", "extra": "y"}}));
assert!(result.is_ok());
let result = registry.validate_values("test", &json!({"config": {"extra": "y"}}));
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_load_values_json_valid() {
let temp_dir = TempDir::new().unwrap();
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let schema_dir = schemas_dir.join("test");
fs::create_dir_all(&schema_dir).unwrap();
fs::write(
schema_dir.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Enable feature"
},
"name": {
"type": "string",
"default": "default",
"description": "Name"
},
"count": {
"type": "integer",
"default": 0,
"description": "Count"
},
"rate": {
"type": "number",
"default": 0.0,
"description": "Rate"
}
}
}"#,
)
.unwrap();
let test_values_dir = values_dir.join("test");
fs::create_dir_all(&test_values_dir).unwrap();
fs::write(
test_values_dir.join("values.json"),
r#"{
"options": {
"enabled": true,
"name": "test-name",
"count": 42,
"rate": 0.75
}
}"#,
)
.unwrap();
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
assert_eq!(values.len(), 1);
assert_eq!(values["test"]["enabled"], json!(true));
assert_eq!(values["test"]["name"], json!("test-name"));
assert_eq!(values["test"]["count"], json!(42));
assert_eq!(values["test"]["rate"], json!(0.75));
assert!(generated_at_by_namespace.is_empty());
}
#[test]
fn test_load_values_json_nonexistent_dir() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{"version": "1.0", "type": "object", "properties": {}}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let (values, generated_at_by_namespace) = registry
.load_values_json(&temp_dir.path().join("nonexistent"))
.unwrap();
assert!(values.is_empty());
assert!(generated_at_by_namespace.is_empty());
}
#[test]
fn test_load_values_json_strips_unknown_keys() {
let temp_dir = TempDir::new().unwrap();
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let schema_dir = schemas_dir.join("test");
fs::create_dir_all(&schema_dir).unwrap();
fs::write(
schema_dir.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"known-option": {
"type": "string",
"default": "default",
"description": "A known option"
}
}
}"#,
)
.unwrap();
let test_values_dir = values_dir.join("test");
fs::create_dir_all(&test_values_dir).unwrap();
fs::write(
test_values_dir.join("values.json"),
r#"{
"options": {
"known-option": "hello",
"unknown-option": "should be stripped"
}
}"#,
)
.unwrap();
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let (values, _) = registry.load_values_json(&values_dir).unwrap();
assert_eq!(values["test"]["known-option"], json!("hello"));
assert!(!values["test"].contains_key("unknown-option"));
}
#[test]
fn test_load_values_json_skips_missing_values_file() {
let temp_dir = TempDir::new().unwrap();
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let schema_dir1 = schemas_dir.join("with-values");
fs::create_dir_all(&schema_dir1).unwrap();
fs::write(
schema_dir1.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"opt": {"type": "string", "default": "x", "description": "Opt"}
}
}"#,
)
.unwrap();
let schema_dir2 = schemas_dir.join("without-values");
fs::create_dir_all(&schema_dir2).unwrap();
fs::write(
schema_dir2.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"opt": {"type": "string", "default": "x", "description": "Opt"}
}
}"#,
)
.unwrap();
let with_values_dir = values_dir.join("with-values");
fs::create_dir_all(&with_values_dir).unwrap();
fs::write(
with_values_dir.join("values.json"),
r#"{"options": {"opt": "y"}}"#,
)
.unwrap();
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let (values, _) = registry.load_values_json(&values_dir).unwrap();
assert_eq!(values.len(), 1);
assert!(values.contains_key("with-values"));
assert!(!values.contains_key("without-values"));
}
#[test]
fn test_load_values_json_extracts_generated_at() {
let temp_dir = TempDir::new().unwrap();
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let schema_dir = schemas_dir.join("test");
fs::create_dir_all(&schema_dir).unwrap();
fs::write(
schema_dir.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": false, "description": "Enabled"}
}
}"#,
)
.unwrap();
let test_values_dir = values_dir.join("test");
fs::create_dir_all(&test_values_dir).unwrap();
fs::write(
test_values_dir.join("values.json"),
r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
)
.unwrap();
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
assert_eq!(values["test"]["enabled"], json!(true));
assert_eq!(
generated_at_by_namespace.get("test"),
Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
);
}
#[test]
fn test_load_values_json_rejects_wrong_type() {
let temp_dir = TempDir::new().unwrap();
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let schema_dir = schemas_dir.join("test");
fs::create_dir_all(&schema_dir).unwrap();
fs::write(
schema_dir.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"count": {"type": "integer", "default": 0, "description": "Count"}
}
}"#,
)
.unwrap();
let test_values_dir = values_dir.join("test");
fs::create_dir_all(&test_values_dir).unwrap();
fs::write(
test_values_dir.join("values.json"),
r#"{"options": {"count": "not-a-number"}}"#,
)
.unwrap();
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
mod feature_flag_tests {
use super::*;
const FEATURE_SCHEMA: &str = r##"{
"version": "1.0",
"type": "object",
"properties": {
"feature.organizations:fury-mode": {
"$ref": "#/definitions/Feature"
}
}
}"##;
#[test]
fn test_schema_with_valid_feature_flag() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
}
#[test]
fn test_schema_with_feature_and_regular_option() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r##"{
"version": "1.0",
"type": "object",
"properties": {
"my-option": {
"type": "string",
"default": "hello",
"description": "A regular option"
},
"feature.organizations:fury-mode": {
"$ref": "#/definitions/Feature"
}
}
}"##,
);
assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
}
#[test]
fn test_schema_with_invalid_feature_definition() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"feature.organizations:fury-mode": {
"nope": "nope"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_validate_values_with_valid_feature_flag() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"feature.organizations:fury-mode": {
"owner": {"team": "hybrid-cloud"},
"segments": [],
"created_at": "2024-01-01"
}
}),
);
assert!(result.is_ok());
}
#[test]
fn test_validate_values_with_feature_flag_missing_required_field_fails() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"feature.organizations:fury-mode": {
"segments": [],
"created_at": "2024-01-01"
}
}),
);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_validate_values_with_feature_flag_invalid_owner_fails() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"feature.organizations:fury-mode": {
"owner": {"email": "test@example.com"},
"segments": [],
"created_at": "2024-01-01"
}
}),
);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_validate_values_feature_with_segments_and_conditions() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"feature.organizations:fury-mode": {
"owner": {"team": "hybrid-cloud"},
"enabled": true,
"created_at": "2024-01-01T00:00:00",
"segments": [
{
"name": "internal orgs",
"rollout": 50,
"conditions": [
{
"property": "organization_slug",
"operator": "in",
"value": ["sentry-test", "sentry"]
}
]
}
]
}
}),
);
assert!(result.is_ok());
}
#[test]
fn test_validate_values_feature_with_multiple_condition_operators() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"feature.organizations:fury-mode": {
"owner": {"team": "hybrid-cloud"},
"created_at": "2024-01-01",
"segments": [
{
"name": "free accounts",
"conditions": [
{
"property": "subscription_is_free",
"operator": "equals",
"value": true
}
]
}
]
}
}),
);
assert!(result.is_ok());
}
#[test]
fn test_validate_values_feature_with_invalid_condition_operator_fails() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"feature.organizations:fury-mode": {
"owner": {"team": "hybrid-cloud"},
"created_at": "2024-01-01",
"segments": [
{
"name": "test segment",
"conditions": [
{
"property": "some_prop",
"operator": "invalid_operator",
"value": "some_value"
}
]
}
]
}
}),
);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_schema_feature_flag_not_in_options_map() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let schema = registry.get("test").unwrap();
assert!(
schema
.get_default("feature.organizations:fury-mode")
.is_none()
);
}
#[test]
fn test_validate_values_feature_and_regular_option_together() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r##"{
"version": "1.0",
"type": "object",
"properties": {
"my-option": {
"type": "string",
"default": "hello",
"description": "A regular option"
},
"feature.organizations:fury-mode": {
"$ref": "#/definitions/Feature"
}
}
}"##,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let result = registry.validate_values(
"test",
&json!({
"my-option": "world",
"feature.organizations:fury-mode": {
"owner": {"team": "hybrid-cloud"},
"segments": [],
"created_at": "2024-01-01"
}
}),
);
assert!(result.is_ok());
}
}
mod watcher_tests {
use super::*;
use std::thread;
fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let schemas_dir = temp_dir.path().join("schemas");
let values_dir = temp_dir.path().join("values");
let ns1_schema = schemas_dir.join("ns1");
fs::create_dir_all(&ns1_schema).unwrap();
fs::write(
ns1_schema.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": false, "description": "Enabled"}
}
}"#,
)
.unwrap();
let ns1_values = values_dir.join("ns1");
fs::create_dir_all(&ns1_values).unwrap();
fs::write(
ns1_values.join("values.json"),
r#"{"options": {"enabled": true}}"#,
)
.unwrap();
let ns2_schema = schemas_dir.join("ns2");
fs::create_dir_all(&ns2_schema).unwrap();
fs::write(
ns2_schema.join("schema.json"),
r#"{
"version": "1.0",
"type": "object",
"properties": {
"count": {"type": "integer", "default": 0, "description": "Count"}
}
}"#,
)
.unwrap();
let ns2_values = values_dir.join("ns2");
fs::create_dir_all(&ns2_values).unwrap();
fs::write(
ns2_values.join("values.json"),
r#"{"options": {"count": 42}}"#,
)
.unwrap();
(temp_dir, schemas_dir, values_dir)
}
#[test]
fn test_get_mtime_returns_most_recent() {
let (_temp, _schemas, values_dir) = setup_watcher_test();
let mtime1 = ValuesWatcher::get_mtime(&values_dir);
assert!(mtime1.is_some());
thread::sleep(std::time::Duration::from_millis(10));
fs::write(
values_dir.join("ns1").join("values.json"),
r#"{"options": {"enabled": false}}"#,
)
.unwrap();
let mtime2 = ValuesWatcher::get_mtime(&values_dir);
assert!(mtime2.is_some());
assert!(mtime2 > mtime1);
}
#[test]
fn test_get_mtime_with_missing_directory() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("nonexistent");
let mtime = ValuesWatcher::get_mtime(&nonexistent);
assert!(mtime.is_none());
}
#[test]
fn test_reload_values_updates_map() {
let (_temp, schemas_dir, values_dir) = setup_watcher_test();
let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
let values = Arc::new(ArcSwap::from_pointee(initial_values));
{
let guard = values.load();
assert_eq!(guard["ns1"]["enabled"], json!(true));
assert_eq!(guard["ns2"]["count"], json!(42));
}
fs::write(
values_dir.join("ns1").join("values.json"),
r#"{"options": {"enabled": false}}"#,
)
.unwrap();
fs::write(
values_dir.join("ns2").join("values.json"),
r#"{"options": {"count": 100}}"#,
)
.unwrap();
ValuesWatcher::reload_values(&values_dir, ®istry, &values);
{
let guard = values.load();
assert_eq!(guard["ns1"]["enabled"], json!(false));
assert_eq!(guard["ns2"]["count"], json!(100));
}
}
#[test]
fn test_old_values_persist_with_invalid_data() {
let (_temp, schemas_dir, values_dir) = setup_watcher_test();
let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
let values = Arc::new(ArcSwap::from_pointee(initial_values));
let initial_enabled = {
let guard = values.load();
guard["ns1"]["enabled"].clone()
};
fs::write(
values_dir.join("ns1").join("values.json"),
r#"{"options": {"enabled": "not-a-boolean"}}"#,
)
.unwrap();
ValuesWatcher::reload_values(&values_dir, ®istry, &values);
{
let guard = values.load();
assert_eq!(guard["ns1"]["enabled"], initial_enabled);
}
}
#[test]
fn test_watcher_creation_and_termination() {
let (_temp, schemas_dir, values_dir) = setup_watcher_test();
let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
let values = Arc::new(ArcSwap::from_pointee(initial_values));
let mut watcher =
ValuesWatcher::new(&values_dir, Arc::clone(®istry), Arc::clone(&values))
.expect("Failed to create watcher");
assert!(watcher.is_alive());
watcher.stop();
assert!(!watcher.is_alive());
}
}
mod array_tests {
use super::*;
#[test]
fn test_basic_schema_validation() {
let temp_dir = TempDir::new().unwrap();
for (a_type, default) in [
("boolean", ""), ("boolean", "true"),
("integer", "1"),
("number", "1.2"),
("string", "\"wow\""),
] {
create_test_schema(
&temp_dir,
"test",
&format!(
r#"{{
"version": "1.0",
"type": "object",
"properties": {{
"array-key": {{
"type": "array",
"items": {{"type": "{}"}},
"default": [{}],
"description": "Array option"
}}
}}
}}"#,
a_type, default
),
);
SchemaRegistry::from_directory(temp_dir.path()).unwrap();
}
}
#[test]
fn test_missing_items_object_rejection() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"default": [1,2,3],
"description": "Array option"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
}
#[test]
fn test_malformed_items_rejection() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"items": {"type": ""},
"default": [1,2,3],
"description": "Array option"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
}
#[test]
fn test_schema_default_type_mismatch_rejection() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"items": {"type": "integer"},
"default": [1,2,3.3],
"description": "Array option"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
}
#[test]
fn test_schema_default_heterogeneous_rejection() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"items": {"type": "integer"},
"default": [1,2,"uh oh!"],
"description": "Array option"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
}
#[test]
fn test_load_values_valid() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"items": {"type": "integer"},
"default": [1,2,3],
"description": "Array option"
}
}
}"#,
r#"{
"options": {
"array-key": [4,5,6]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let (values, generated_at_by_namespace) =
registry.load_values_json(&values_dir).unwrap();
assert_eq!(values.len(), 1);
assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
assert!(generated_at_by_namespace.is_empty());
}
#[test]
fn test_reject_values_not_an_array() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"items": {"type": "integer"},
"default": [1,2,3],
"description": "Array option"
}
}
}"#,
r#"{
"options": {
"array-key": "[]"
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_reject_values_mismatch() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"array-key": {
"type": "array",
"items": {"type": "integer"},
"default": [1,2,3],
"description": "Array option"
}
}
}"#,
r#"{
"options": {
"array-key": ["a","b","c"]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
}
mod object_tests {
use super::*;
#[test]
fn test_object_schema_loads() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"},
"rate": {"type": "number"},
"enabled": {"type": "boolean"}
},
"default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
"description": "Service config"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let schema = registry.get("test").unwrap();
assert_eq!(schema.options["config"].option_type, "object");
}
#[test]
fn test_object_missing_properties_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"default": {"host": "localhost"},
"description": "Missing properties field"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_object_default_wrong_type_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
},
"default": {"host": "localhost", "port": "not-a-number"},
"description": "Bad default"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_object_default_missing_field_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
},
"default": {"host": "localhost"},
"description": "Missing port in default"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_object_default_extra_field_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"}
},
"default": {"host": "localhost", "extra": "field"},
"description": "Extra field in default"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_object_values_valid() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
},
"default": {"host": "localhost", "port": 8080},
"description": "Service config"
}
}
}"#,
r#"{
"options": {
"config": {"host": "example.com", "port": 9090}
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(result.is_ok());
}
#[test]
fn test_object_values_wrong_field_type_rejected() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
},
"default": {"host": "localhost", "port": 8080},
"description": "Service config"
}
}
}"#,
r#"{
"options": {
"config": {"host": "example.com", "port": "not-a-number"}
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_object_values_extra_field_rejected() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"}
},
"default": {"host": "localhost"},
"description": "Service config"
}
}
}"#,
r#"{
"options": {
"config": {"host": "example.com", "extra": "field"}
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_object_values_missing_field_rejected() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"}
},
"default": {"host": "localhost", "port": 8080},
"description": "Service config"
}
}
}"#,
r#"{
"options": {
"config": {"host": "example.com"}
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_array_of_objects_schema_loads() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer"}
}
},
"default": [{"url": "https://a.example.com", "weight": 1}],
"description": "Endpoints"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let schema = registry.get("test").unwrap();
assert_eq!(schema.options["endpoints"].option_type, "array");
}
#[test]
fn test_array_of_objects_empty_default() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer"}
}
},
"default": [],
"description": "Endpoints"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
assert!(registry.get("test").is_some());
}
#[test]
fn test_array_of_objects_default_wrong_field_type_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer"}
}
},
"default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
"description": "Endpoints"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_array_of_objects_missing_items_properties_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object"
},
"default": [],
"description": "Missing properties in items"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_array_of_objects_values_valid() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer"}
}
},
"default": [],
"description": "Endpoints"
}
}
}"#,
r#"{
"options": {
"endpoints": [
{"url": "https://a.example.com", "weight": 1},
{"url": "https://b.example.com", "weight": 2}
]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(result.is_ok());
}
#[test]
fn test_array_of_objects_values_wrong_item_shape_rejected() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer"}
}
},
"default": [],
"description": "Endpoints"
}
}
}"#,
r#"{
"options": {
"endpoints": [
{"url": "https://a.example.com", "weight": "not-a-number"}
]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_array_of_objects_values_extra_field_rejected() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"}
}
},
"default": [],
"description": "Endpoints"
}
}
}"#,
r#"{
"options": {
"endpoints": [
{"url": "https://a.example.com", "extra": "field"}
]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_array_of_objects_values_missing_field_rejected() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer"}
}
},
"default": [],
"description": "Endpoints"
}
}
}"#,
r#"{
"options": {
"endpoints": [
{"url": "https://a.example.com"}
]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(matches!(result, Err(ValidationError::ValueError { .. })));
}
#[test]
fn test_object_optional_field_can_be_omitted_from_default() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"debug": {"type": "boolean", "optional": true}
},
"default": {"host": "localhost"},
"description": "Config with optional field"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
let schema = registry.get("test").unwrap();
assert_eq!(schema.options["config"].option_type, "object");
}
#[test]
fn test_object_optional_field_can_be_included_in_default() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"debug": {"type": "boolean", "optional": true}
},
"default": {"host": "localhost", "debug": true},
"description": "Config with optional field included"
}
}
}"#,
);
let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
assert!(registry.get("test").is_some());
}
#[test]
fn test_object_optional_field_wrong_type_rejected() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"debug": {"type": "boolean", "optional": true}
},
"default": {"host": "localhost", "debug": "not-a-bool"},
"description": "Optional field wrong type"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_object_required_field_still_required_with_optional_present() {
let temp_dir = TempDir::new().unwrap();
create_test_schema(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"},
"debug": {"type": "boolean", "optional": true}
},
"default": {"debug": true},
"description": "Missing required fields"
}
}
}"#,
);
let result = SchemaRegistry::from_directory(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_object_optional_field_omitted_from_values() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"debug": {"type": "boolean", "optional": true}
},
"default": {"host": "localhost"},
"description": "Config"
}
}
}"#,
r#"{
"options": {
"config": {"host": "example.com"}
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(result.is_ok());
}
#[test]
fn test_object_optional_field_included_in_values() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"host": {"type": "string"},
"debug": {"type": "boolean", "optional": true}
},
"default": {"host": "localhost"},
"description": "Config"
}
}
}"#,
r#"{
"options": {
"config": {"host": "example.com", "debug": true}
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(result.is_ok());
}
#[test]
fn test_array_of_objects_optional_field_omitted() {
let temp_dir = TempDir::new().unwrap();
let (schemas_dir, values_dir) = create_test_schema_with_values(
&temp_dir,
"test",
r#"{
"version": "1.0",
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {"type": "string"},
"weight": {"type": "integer", "optional": true}
}
},
"default": [],
"description": "Endpoints"
}
}
}"#,
r#"{
"options": {
"endpoints": [
{"url": "https://a.example.com"},
{"url": "https://b.example.com", "weight": 2}
]
}
}"#,
);
let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
let result = registry.load_values_json(&values_dir);
assert!(result.is_ok());
}
}
}