An ATProto Lexicon validator for Gleam.

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

Changed files
+155 -54
src
honk
test
+11
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## 1.0.1 4 + 5 + ### Fixed 6 + 7 + - Fix `is_null_dynamic` to use `dynamic.classify` for consistent null detection 8 + 9 + ## 1.0.0 10 + 11 + - Initial release
+1 -1
gleam.toml
··· 1 1 name = "honk" 2 - version = "1.0.0" 2 + version = "1.0.1" 3 3 description = "ATProtocol lexicon validator for Gleam" 4 4 internal_modules = ["honk/internal", "honk/internal/*"] 5 5 licences = ["Apache-2.0"]
+51 -53
src/honk/internal/json_helpers.gleam
··· 221 221 222 222 /// Check if dynamic value is null 223 223 pub fn is_null_dynamic(dyn: Dynamic) -> Bool { 224 - case decode.run(dyn, decode.string) { 225 - Ok("null") -> True 226 - _ -> False 227 - } 224 + dynamic.classify(dyn) == "Nil" 228 225 } 229 226 230 227 /// Convert JSON object to a dictionary ··· 243 240 } 244 241 245 242 /// Convert a dynamic value back to Json 246 - /// This works by trying different decoders 247 243 pub fn dynamic_to_json(dyn: Dynamic) -> Result(Json, ValidationError) { 248 - // Try null 249 - case decode.run(dyn, decode.string) { 250 - Ok(s) -> { 251 - case s { 252 - "null" -> Ok(json.null()) 253 - _ -> Ok(json.string(s)) 244 + case dynamic.classify(dyn) { 245 + "Nil" -> Ok(json.null()) 246 + "String" -> { 247 + case decode.run(dyn, decode.string) { 248 + Ok(s) -> Ok(json.string(s)) 249 + Error(_) -> Error(data_validation("Failed to decode string")) 254 250 } 255 251 } 256 - Error(_) -> { 257 - // Try number 252 + "Int" -> { 258 253 case decode.run(dyn, decode.int) { 259 254 Ok(i) -> Ok(json.int(i)) 260 - Error(_) -> { 261 - // Try boolean 262 - case decode.run(dyn, decode.bool) { 263 - Ok(b) -> Ok(json.bool(b)) 264 - Error(_) -> { 265 - // Try array 266 - case decode.run(dyn, decode.list(decode.dynamic)) { 267 - Ok(arr) -> { 268 - // Recursively convert array items 269 - case list.try_map(arr, dynamic_to_json) { 270 - Ok(json_arr) -> Ok(json.array(json_arr, fn(x) { x })) 271 - Error(e) -> Error(e) 272 - } 273 - } 274 - Error(_) -> { 275 - // Try object 276 - case 277 - decode.run(dyn, decode.dict(decode.string, decode.dynamic)) 278 - { 279 - Ok(dict_val) -> { 280 - // Convert dict to object 281 - let pairs = dict.to_list(dict_val) 282 - case 283 - list.try_map(pairs, fn(pair) { 284 - let #(key, value_dyn) = pair 285 - case dynamic_to_json(value_dyn) { 286 - Ok(value_json) -> Ok(#(key, value_json)) 287 - Error(e) -> Error(e) 288 - } 289 - }) 290 - { 291 - Ok(json_pairs) -> Ok(json.object(json_pairs)) 292 - Error(e) -> Error(e) 293 - } 294 - } 295 - Error(_) -> 296 - Error(data_validation("Failed to convert dynamic to Json")) 297 - } 298 - } 255 + Error(_) -> Error(data_validation("Failed to decode int")) 256 + } 257 + } 258 + "Float" -> { 259 + case decode.run(dyn, decode.float) { 260 + Ok(f) -> Ok(json.float(f)) 261 + Error(_) -> Error(data_validation("Failed to decode float")) 262 + } 263 + } 264 + "Bool" -> { 265 + case decode.run(dyn, decode.bool) { 266 + Ok(b) -> Ok(json.bool(b)) 267 + Error(_) -> Error(data_validation("Failed to decode bool")) 268 + } 269 + } 270 + "List" -> { 271 + case decode.run(dyn, decode.list(decode.dynamic)) { 272 + Ok(arr) -> { 273 + case list.try_map(arr, dynamic_to_json) { 274 + Ok(json_arr) -> Ok(json.array(json_arr, fn(x) { x })) 275 + Error(e) -> Error(e) 276 + } 277 + } 278 + Error(_) -> Error(data_validation("Failed to decode list")) 279 + } 280 + } 281 + "Dict" -> { 282 + case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { 283 + Ok(dict_val) -> { 284 + let pairs = dict.to_list(dict_val) 285 + case 286 + list.try_map(pairs, fn(pair) { 287 + let #(key, value_dyn) = pair 288 + case dynamic_to_json(value_dyn) { 289 + Ok(value_json) -> Ok(#(key, value_json)) 290 + Error(e) -> Error(e) 299 291 } 300 - } 292 + }) 293 + { 294 + Ok(json_pairs) -> Ok(json.object(json_pairs)) 295 + Error(e) -> Error(e) 301 296 } 302 297 } 298 + Error(_) -> Error(data_validation("Failed to decode dict")) 303 299 } 304 300 } 301 + other -> 302 + Error(data_validation("Unsupported type for JSON conversion: " <> other)) 305 303 } 306 304 } 307 305
+92
test/object_validator_test.gleam
··· 99 99 error_message 100 100 |> should.equal("Data validation failed: required field 'title' is missing") 101 101 } 102 + 103 + // Test nullable field accepts null value 104 + pub fn nullable_field_accepts_null_test() { 105 + let schema = 106 + json.object([ 107 + #("type", json.string("object")), 108 + #( 109 + "properties", 110 + json.object([ 111 + #("name", json.object([#("type", json.string("string"))])), 112 + #("duration", json.object([#("type", json.string("integer"))])), 113 + ]), 114 + ), 115 + #("nullable", json.array([json.string("duration")], fn(x) { x })), 116 + ]) 117 + 118 + let data = 119 + json.object([ 120 + #("name", json.string("test")), 121 + #("duration", json.null()), 122 + ]) 123 + 124 + let assert Ok(ctx) = context.builder() |> context.build 125 + let result = field.validate_object_data(data, schema, ctx) 126 + result |> should.be_ok 127 + } 128 + 129 + // Test non-nullable field rejects null value 130 + pub fn non_nullable_field_rejects_null_test() { 131 + let schema = 132 + json.object([ 133 + #("type", json.string("object")), 134 + #( 135 + "properties", 136 + json.object([ 137 + #("name", json.object([#("type", json.string("string"))])), 138 + #("count", json.object([#("type", json.string("integer"))])), 139 + ]), 140 + ), 141 + // No nullable array - count cannot be null 142 + ]) 143 + 144 + let data = 145 + json.object([ 146 + #("name", json.string("test")), 147 + #("count", json.null()), 148 + ]) 149 + 150 + let assert Ok(ctx) = context.builder() |> context.build 151 + let result = field.validate_object_data(data, schema, ctx) 152 + result |> should.be_error 153 + } 154 + 155 + // Test nullable field must exist in properties (schema validation) 156 + pub fn nullable_field_not_in_properties_fails_test() { 157 + let schema = 158 + json.object([ 159 + #("type", json.string("object")), 160 + #( 161 + "properties", 162 + json.object([ 163 + #("name", json.object([#("type", json.string("string"))])), 164 + ]), 165 + ), 166 + // "nonexistent" is not in properties 167 + #("nullable", json.array([json.string("nonexistent")], fn(x) { x })), 168 + ]) 169 + 170 + let assert Ok(ctx) = context.builder() |> context.build 171 + let result = field.validate_object_schema(schema, ctx) 172 + result |> should.be_error 173 + } 174 + 175 + // Test valid nullable schema passes validation 176 + pub fn valid_nullable_schema_test() { 177 + let schema = 178 + json.object([ 179 + #("type", json.string("object")), 180 + #( 181 + "properties", 182 + json.object([ 183 + #("name", json.object([#("type", json.string("string"))])), 184 + #("duration", json.object([#("type", json.string("integer"))])), 185 + ]), 186 + ), 187 + #("nullable", json.array([json.string("duration")], fn(x) { x })), 188 + ]) 189 + 190 + let assert Ok(ctx) = context.builder() |> context.build 191 + let result = field.validate_object_schema(schema, ctx) 192 + result |> should.be_ok 193 + }