Skip to content

Commit 3584a94

Browse files
author
Clemens Vasters
committed
fix(rust): decimal validation now accepts strings per spec
- Add validate_decimal() that accepts strings (spec) and numbers (convenience) - Fix tagged choice validation for wrapper objects like {"creditCard": {...}} - Add InstanceDecimalExpected error code - Replace broken test_decimal_valid with comprehensive decimal tests - Add test_sample_instances_are_valid for primer-and-samples coverage Fixes decimal types which per spec 'are represented as strings to preserve precision'. Now consistent with TypeScript, Go, and Python SDKs. All 246 tests pass including 34 primer-and-samples instance tests.
1 parent 845d0bd commit 3584a94

File tree

4 files changed

+310
-6
lines changed

4 files changed

+310
-6
lines changed

rust/src/error_codes.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ pub enum InstanceErrorCode {
215215
InstanceNumberNotMultiple,
216216
InstanceIntegerExpected,
217217
InstanceIntegerOutOfRange,
218+
InstanceDecimalExpected,
218219

219220
// Boolean/Null errors
220221
InstanceBooleanExpected,
@@ -326,6 +327,7 @@ impl InstanceErrorCode {
326327
Self::InstanceNumberNotMultiple => "INSTANCE_NUMBER_NOT_MULTIPLE",
327328
Self::InstanceIntegerExpected => "INSTANCE_INTEGER_EXPECTED",
328329
Self::InstanceIntegerOutOfRange => "INSTANCE_INTEGER_OUT_OF_RANGE",
330+
Self::InstanceDecimalExpected => "INSTANCE_DECIMAL_EXPECTED",
329331
Self::InstanceBooleanExpected => "INSTANCE_BOOLEAN_EXPECTED",
330332
Self::InstanceNullExpected => "INSTANCE_NULL_EXPECTED",
331333
Self::InstanceObjectExpected => "INSTANCE_OBJECT_EXPECTED",

rust/src/instance_validator.rs

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,10 @@ impl InstanceValidator {
301301
"uint32" => self.validate_uint32(instance, schema_obj, result, path, locator),
302302
"uint64" => self.validate_uint64(instance, schema_obj, result, path, locator),
303303
"uint128" => self.validate_uint128(instance, schema_obj, result, path, locator),
304-
"float" | "float8" | "double" | "decimal" => {
304+
"float" | "float8" | "double" => {
305305
self.validate_number(instance, schema_obj, result, path, locator)
306306
}
307+
"decimal" => self.validate_decimal(instance, schema_obj, result, path, locator),
307308
"date" => self.validate_date(instance, result, path, locator),
308309
"time" => self.validate_time(instance, result, path, locator),
309310
"datetime" => self.validate_datetime(instance, result, path, locator),
@@ -514,6 +515,128 @@ impl InstanceValidator {
514515
}
515516
}
516517

518+
/// Validates a decimal value.
519+
/// Per the JSON Structure spec, decimal values are represented as strings
520+
/// to preserve arbitrary precision. Numbers are also accepted for convenience.
521+
fn validate_decimal(
522+
&self,
523+
instance: &Value,
524+
schema_obj: &serde_json::Map<String, Value>,
525+
result: &mut ValidationResult,
526+
path: &str,
527+
locator: &JsonSourceLocator,
528+
) {
529+
let value: f64 = match instance {
530+
Value::String(s) => {
531+
// Decimal values should be strings per spec
532+
match s.parse::<f64>() {
533+
Ok(v) => v,
534+
Err(_) => {
535+
result.add_error(ValidationError::instance_error(
536+
InstanceErrorCode::InstanceDecimalExpected,
537+
format!("Invalid decimal format: {}", s),
538+
path,
539+
locator.get_location(path),
540+
));
541+
return;
542+
}
543+
}
544+
}
545+
Value::Number(n) => {
546+
// Also accept numbers for convenience (though strings preferred)
547+
n.as_f64().unwrap_or(0.0)
548+
}
549+
_ => {
550+
result.add_error(ValidationError::instance_error(
551+
InstanceErrorCode::InstanceDecimalExpected,
552+
"Expected decimal (as string or number)",
553+
path,
554+
locator.get_location(path),
555+
));
556+
return;
557+
}
558+
};
559+
560+
// Apply numeric constraints if extended validation is enabled
561+
if self.options.extended {
562+
// minimum
563+
if let Some(min_val) = schema_obj.get("minimum") {
564+
let min = match min_val {
565+
Value::Number(n) => n.as_f64(),
566+
Value::String(s) => s.parse::<f64>().ok(),
567+
_ => None,
568+
};
569+
if let Some(min) = min {
570+
if value < min {
571+
result.add_error(ValidationError::instance_error(
572+
InstanceErrorCode::InstanceNumberTooSmall,
573+
format!("Value {} is less than minimum {}", value, min),
574+
path,
575+
locator.get_location(path),
576+
));
577+
}
578+
}
579+
}
580+
581+
// maximum
582+
if let Some(max_val) = schema_obj.get("maximum") {
583+
let max = match max_val {
584+
Value::Number(n) => n.as_f64(),
585+
Value::String(s) => s.parse::<f64>().ok(),
586+
_ => None,
587+
};
588+
if let Some(max) = max {
589+
if value > max {
590+
result.add_error(ValidationError::instance_error(
591+
InstanceErrorCode::InstanceNumberTooLarge,
592+
format!("Value {} is greater than maximum {}", value, max),
593+
path,
594+
locator.get_location(path),
595+
));
596+
}
597+
}
598+
}
599+
600+
// exclusiveMinimum
601+
if let Some(min_val) = schema_obj.get("exclusiveMinimum") {
602+
let min = match min_val {
603+
Value::Number(n) => n.as_f64(),
604+
Value::String(s) => s.parse::<f64>().ok(),
605+
_ => None,
606+
};
607+
if let Some(min) = min {
608+
if value <= min {
609+
result.add_error(ValidationError::instance_error(
610+
InstanceErrorCode::InstanceNumberTooSmall,
611+
format!("Value {} is not greater than exclusive minimum {}", value, min),
612+
path,
613+
locator.get_location(path),
614+
));
615+
}
616+
}
617+
}
618+
619+
// exclusiveMaximum
620+
if let Some(max_val) = schema_obj.get("exclusiveMaximum") {
621+
let max = match max_val {
622+
Value::Number(n) => n.as_f64(),
623+
Value::String(s) => s.parse::<f64>().ok(),
624+
_ => None,
625+
};
626+
if let Some(max) = max {
627+
if value >= max {
628+
result.add_error(ValidationError::instance_error(
629+
InstanceErrorCode::InstanceNumberTooLarge,
630+
format!("Value {} is not less than exclusive maximum {}", value, max),
631+
path,
632+
locator.get_location(path),
633+
));
634+
}
635+
}
636+
}
637+
}
638+
}
639+
517640
fn validate_int32(
518641
&self,
519642
instance: &Value,
@@ -1674,7 +1797,33 @@ impl InstanceValidator {
16741797
));
16751798
}
16761799
} else {
1677-
// Untagged choice - try to match one
1800+
// Tagged choice (no selector) - instance should be an object with one property
1801+
// matching a choice name, e.g., {"creditCard": {...}}
1802+
let obj = match instance {
1803+
Value::Object(o) => o,
1804+
_ => {
1805+
result.add_error(ValidationError::instance_error(
1806+
InstanceErrorCode::InstanceChoiceNoMatch,
1807+
"Value does not match any choice option",
1808+
path,
1809+
locator.get_location(path),
1810+
));
1811+
return;
1812+
}
1813+
};
1814+
1815+
// Check if it's a tagged union (object with exactly one property matching a choice)
1816+
if obj.len() == 1 {
1817+
let (tag, value) = obj.iter().next().unwrap();
1818+
if let Some(choice_schema) = choices.get(tag) {
1819+
// Validate the wrapped value against the choice schema
1820+
let value_path = format!("{}/{}", path, tag);
1821+
self.validate_instance(value, choice_schema, root_schema, result, &value_path, locator, depth + 1);
1822+
return;
1823+
}
1824+
}
1825+
1826+
// If not a tagged union, try untagged choice - try to match one
16781827
let mut match_count = 0;
16791828

16801829
for (_choice_name, choice_schema) in choices {

rust/tests/instance_validator_tests.rs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,17 +226,72 @@ fn test_double_valid() {
226226
}
227227

228228
#[test]
229-
fn test_decimal_valid() {
229+
fn test_decimal_string_valid() {
230+
// Per the JSON Structure spec, decimal types are represented as strings
231+
// to preserve arbitrary precision
232+
let schema = json!({
233+
"type": "decimal"
234+
});
235+
let validator = InstanceValidator::new();
236+
237+
// String format is the spec-compliant way
238+
let result = validator.validate(r#""123.456""#, &schema);
239+
assert!(result.is_valid(), "String '123.456' should be valid decimal");
240+
241+
let result = validator.validate(r#""0.0875""#, &schema);
242+
assert!(result.is_valid(), "String '0.0875' should be valid decimal");
243+
244+
let result = validator.validate(r#""-999.99""#, &schema);
245+
assert!(result.is_valid(), "Negative string decimal should be valid");
246+
247+
let result = validator.validate(r#""12345678901234567890.123456789""#, &schema);
248+
assert!(result.is_valid(), "High-precision string decimal should be valid");
249+
}
250+
251+
#[test]
252+
fn test_decimal_number_also_valid() {
253+
// Numbers are also accepted for convenience, though strings are preferred
230254
let schema = json!({
231255
"type": "decimal"
232256
});
233257
let validator = InstanceValidator::new();
234258

235259
let result = validator.validate("123.456", &schema);
236-
assert!(result.is_valid(), "123.456 should be valid decimal");
260+
assert!(result.is_valid(), "JSON number 123.456 should be valid decimal");
261+
262+
let result = validator.validate("-99.5", &schema);
263+
assert!(result.is_valid(), "Negative JSON number should be valid decimal");
264+
}
265+
266+
#[test]
267+
fn test_decimal_invalid_string_format() {
268+
let schema = json!({
269+
"type": "decimal"
270+
});
271+
let validator = InstanceValidator::new();
272+
273+
let result = validator.validate(r#""not-a-number""#, &schema);
274+
assert!(!result.is_valid(), "Non-numeric string should fail decimal validation");
275+
276+
let result = validator.validate(r#""12.34.56""#, &schema);
277+
assert!(!result.is_valid(), "Malformed decimal string should fail");
278+
}
279+
280+
#[test]
281+
fn test_decimal_wrong_type() {
282+
let schema = json!({
283+
"type": "decimal"
284+
});
285+
let validator = InstanceValidator::new();
286+
287+
let result = validator.validate("true", &schema);
288+
assert!(!result.is_valid(), "Boolean should fail decimal validation");
289+
290+
let result = validator.validate("null", &schema);
291+
assert!(!result.is_valid(), "Null should fail decimal validation");
237292

238-
// Note: String decimals may or may not be supported depending on implementation
239-
// The primary format is JSON number
293+
let result = validator.validate(r#"["123.45"]"#, &schema);
294+
assert!(!result.is_valid(), "Array should fail decimal validation");
240295
}
241296

242297
// =============================================================================

rust/tests/test_assets_tests.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ fn prepare_instance_for_validation(instance: &Value, schema: &Value) -> Value {
347347
obj.remove("_description");
348348
obj.remove("_expectedError");
349349
obj.remove("_expectedValid");
350+
// $schema is a meta-annotation, not instance data
351+
obj.remove("$schema");
350352
}
351353

352354
// Check if schema expects a primitive/array type and instance has { value: ... } wrapper
@@ -556,3 +558,99 @@ fn test_sample_schemas_are_valid() {
556558
// Note: Some tests fail due to union types (type as array) not being supported yet
557559
// assert_eq!(failed, 0, "Some sample schemas failed validation");
558560
}
561+
562+
/// Test that sample instance files from primer-and-samples validate against their schemas
563+
#[test]
564+
fn test_sample_instances_are_valid() {
565+
let samples_dir = match find_samples_dir() {
566+
Some(dir) => dir,
567+
None => {
568+
eprintln!("samples directory not found, skipping test");
569+
return;
570+
}
571+
};
572+
573+
let mut passed = 0;
574+
let mut failed = 0;
575+
let mut skipped = 0;
576+
577+
// Get all sample directories
578+
let sample_dirs = get_subdirs(&samples_dir);
579+
580+
for sample_dir in &sample_dirs {
581+
let schema_file = sample_dir.join("schema.struct.json");
582+
if !schema_file.exists() {
583+
continue;
584+
}
585+
586+
let category_name = get_test_name(sample_dir);
587+
588+
// Load the schema
589+
let schema = match load_json(&schema_file) {
590+
Some(s) => s,
591+
None => {
592+
eprintln!(" ✗ {} - failed to load schema", category_name);
593+
failed += 1;
594+
continue;
595+
}
596+
};
597+
598+
// Find example*.json files in this directory
599+
let instance_files: Vec<PathBuf> = fs::read_dir(sample_dir)
600+
.map(|entries| {
601+
entries
602+
.filter_map(|e| e.ok())
603+
.map(|e| e.path())
604+
.filter(|p| {
605+
let name = p.file_name().unwrap_or_default().to_string_lossy();
606+
name.starts_with("example") && name.ends_with(".json")
607+
})
608+
.collect()
609+
})
610+
.unwrap_or_default();
611+
612+
if instance_files.is_empty() {
613+
skipped += 1;
614+
continue;
615+
}
616+
617+
let mut validator = InstanceValidator::new();
618+
validator.set_extended(true);
619+
620+
for instance_file in &instance_files {
621+
let instance_name = instance_file.file_name()
622+
.map(|n| n.to_string_lossy().to_string())
623+
.unwrap_or_else(|| "unknown".to_string());
624+
625+
let instance_data = match load_json(instance_file) {
626+
Some(d) => d,
627+
None => {
628+
eprintln!(" ✗ {}/{} - failed to load instance", category_name, instance_name);
629+
failed += 1;
630+
continue;
631+
}
632+
};
633+
634+
// Prepare instance (remove $schema and other meta fields)
635+
let instance = prepare_instance_for_validation(&instance_data, &schema);
636+
let instance_json = serde_json::to_string(&instance).unwrap();
637+
638+
let result = validator.validate(&instance_json, &schema);
639+
640+
if result.is_valid() {
641+
passed += 1;
642+
} else {
643+
eprintln!(
644+
" ✗ {}/{} - should be valid: {:?}",
645+
category_name,
646+
instance_name,
647+
result.errors().next()
648+
);
649+
failed += 1;
650+
}
651+
}
652+
}
653+
654+
println!("Sample Instance Tests: {} passed, {} failed, {} skipped", passed, failed, skipped);
655+
assert_eq!(failed, 0, "Some sample instances failed validation");
656+
}

0 commit comments

Comments
 (0)