From 6a629bba09897b435f22887e13d8a138ea1e01ca Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Fri, 28 Nov 2025 12:18:35 -0800 Subject: [PATCH] add nullable field tests and fix null detection - Fix is_null_dynamic to use dynamic.classify for consistency - Add test: nullable field accepts null value - Add test: non-nullable field rejects null value - Add test: nullable field not in properties fails schema validation - Add test: valid nullable schema passes validation --- CHANGELOG.md | 11 +++ gleam.toml | 2 +- src/honk/internal/json_helpers.gleam | 104 +++++++++++++-------------- test/object_validator_test.gleam | 92 ++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 54 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f889302 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 1.0.1 + +### Fixed + +- Fix `is_null_dynamic` to use `dynamic.classify` for consistent null detection + +## 1.0.0 + +- Initial release diff --git a/gleam.toml b/gleam.toml index 988ff72..2f56b6a 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "honk" -version = "1.0.0" +version = "1.0.1" description = "ATProtocol lexicon validator for Gleam" internal_modules = ["honk/internal", "honk/internal/*"] licences = ["Apache-2.0"] diff --git a/src/honk/internal/json_helpers.gleam b/src/honk/internal/json_helpers.gleam index e027a7c..333cf40 100644 --- a/src/honk/internal/json_helpers.gleam +++ b/src/honk/internal/json_helpers.gleam @@ -221,10 +221,7 @@ pub fn get_array_from_value(json_value: Json) -> Option(List(Dynamic)) { /// Check if dynamic value is null pub fn is_null_dynamic(dyn: Dynamic) -> Bool { - case decode.run(dyn, decode.string) { - Ok("null") -> True - _ -> False - } + dynamic.classify(dyn) == "Nil" } /// Convert JSON object to a dictionary @@ -243,65 +240,66 @@ pub fn json_to_dict( } /// Convert a dynamic value back to Json -/// This works by trying different decoders pub fn dynamic_to_json(dyn: Dynamic) -> Result(Json, ValidationError) { - // Try null - case decode.run(dyn, decode.string) { - Ok(s) -> { - case s { - "null" -> Ok(json.null()) - _ -> Ok(json.string(s)) + case dynamic.classify(dyn) { + "Nil" -> Ok(json.null()) + "String" -> { + case decode.run(dyn, decode.string) { + Ok(s) -> Ok(json.string(s)) + Error(_) -> Error(data_validation("Failed to decode string")) } } - Error(_) -> { - // Try number + "Int" -> { case decode.run(dyn, decode.int) { Ok(i) -> Ok(json.int(i)) - Error(_) -> { - // Try boolean - case decode.run(dyn, decode.bool) { - Ok(b) -> Ok(json.bool(b)) - Error(_) -> { - // Try array - case decode.run(dyn, decode.list(decode.dynamic)) { - Ok(arr) -> { - // Recursively convert array items - case list.try_map(arr, dynamic_to_json) { - Ok(json_arr) -> Ok(json.array(json_arr, fn(x) { x })) - Error(e) -> Error(e) - } - } - Error(_) -> { - // Try object - case - decode.run(dyn, decode.dict(decode.string, decode.dynamic)) - { - Ok(dict_val) -> { - // Convert dict to object - let pairs = dict.to_list(dict_val) - case - list.try_map(pairs, fn(pair) { - let #(key, value_dyn) = pair - case dynamic_to_json(value_dyn) { - Ok(value_json) -> Ok(#(key, value_json)) - Error(e) -> Error(e) - } - }) - { - Ok(json_pairs) -> Ok(json.object(json_pairs)) - Error(e) -> Error(e) - } - } - Error(_) -> - Error(data_validation("Failed to convert dynamic to Json")) - } - } + Error(_) -> Error(data_validation("Failed to decode int")) + } + } + "Float" -> { + case decode.run(dyn, decode.float) { + Ok(f) -> Ok(json.float(f)) + Error(_) -> Error(data_validation("Failed to decode float")) + } + } + "Bool" -> { + case decode.run(dyn, decode.bool) { + Ok(b) -> Ok(json.bool(b)) + Error(_) -> Error(data_validation("Failed to decode bool")) + } + } + "List" -> { + case decode.run(dyn, decode.list(decode.dynamic)) { + Ok(arr) -> { + case list.try_map(arr, dynamic_to_json) { + Ok(json_arr) -> Ok(json.array(json_arr, fn(x) { x })) + Error(e) -> Error(e) + } + } + Error(_) -> Error(data_validation("Failed to decode list")) + } + } + "Dict" -> { + case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { + Ok(dict_val) -> { + let pairs = dict.to_list(dict_val) + case + list.try_map(pairs, fn(pair) { + let #(key, value_dyn) = pair + case dynamic_to_json(value_dyn) { + Ok(value_json) -> Ok(#(key, value_json)) + Error(e) -> Error(e) } - } + }) + { + Ok(json_pairs) -> Ok(json.object(json_pairs)) + Error(e) -> Error(e) } } + Error(_) -> Error(data_validation("Failed to decode dict")) } } + other -> + Error(data_validation("Unsupported type for JSON conversion: " <> other)) } } diff --git a/test/object_validator_test.gleam b/test/object_validator_test.gleam index 1b0a7c4..a55eb9a 100644 --- a/test/object_validator_test.gleam +++ b/test/object_validator_test.gleam @@ -99,3 +99,95 @@ pub fn missing_required_field_message_root_test() { error_message |> should.equal("Data validation failed: required field 'title' is missing") } + +// Test nullable field accepts null value +pub fn nullable_field_accepts_null_test() { + let schema = + json.object([ + #("type", json.string("object")), + #( + "properties", + json.object([ + #("name", json.object([#("type", json.string("string"))])), + #("duration", json.object([#("type", json.string("integer"))])), + ]), + ), + #("nullable", json.array([json.string("duration")], fn(x) { x })), + ]) + + let data = + json.object([ + #("name", json.string("test")), + #("duration", json.null()), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = field.validate_object_data(data, schema, ctx) + result |> should.be_ok +} + +// Test non-nullable field rejects null value +pub fn non_nullable_field_rejects_null_test() { + let schema = + json.object([ + #("type", json.string("object")), + #( + "properties", + json.object([ + #("name", json.object([#("type", json.string("string"))])), + #("count", json.object([#("type", json.string("integer"))])), + ]), + ), + // No nullable array - count cannot be null + ]) + + let data = + json.object([ + #("name", json.string("test")), + #("count", json.null()), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = field.validate_object_data(data, schema, ctx) + result |> should.be_error +} + +// Test nullable field must exist in properties (schema validation) +pub fn nullable_field_not_in_properties_fails_test() { + let schema = + json.object([ + #("type", json.string("object")), + #( + "properties", + json.object([ + #("name", json.object([#("type", json.string("string"))])), + ]), + ), + // "nonexistent" is not in properties + #("nullable", json.array([json.string("nonexistent")], fn(x) { x })), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = field.validate_object_schema(schema, ctx) + result |> should.be_error +} + +// Test valid nullable schema passes validation +pub fn valid_nullable_schema_test() { + let schema = + json.object([ + #("type", json.string("object")), + #( + "properties", + json.object([ + #("name", json.object([#("type", json.string("string"))])), + #("duration", json.object([#("type", json.string("integer"))])), + ]), + ), + #("nullable", json.array([json.string("duration")], fn(x) { x })), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = field.validate_object_schema(schema, ctx) + result |> should.be_ok +} -- 2.51.2