An ATProto Lexicon validator for Gleam.

fix validation error message formatting

- Fix double colon bug when validation path is empty
- Add defs.main prefix to data validation errors for clarity
- Add comprehensive tests for error message formats
- Schema validation errors show definition via #defName prefix
- Data validation errors include defs.main in path

+3 -1
src/honk.gleam
··· 170 170 case json_helpers.get_field(lexicon.defs, "main") { 171 171 Some(main_def) -> { 172 172 let lex_ctx = context.with_current_lexicon(ctx, collection) 173 + // Set the path to include the definition name 174 + let def_ctx = context.with_path(lex_ctx, "defs.main") 173 175 // Validate the record data against the main definition 174 - validation_primary_record.validate_data(record, main_def, lex_ctx) 176 + validation_primary_record.validate_data(record, main_def, def_ctx) 175 177 } 176 178 None -> 177 179 Error(errors.invalid_schema(
+7 -4
src/honk/validation/field.gleam
··· 356 356 list.try_fold(field_names, Nil, fn(_, field_name) { 357 357 case json_helpers.get_field(data, field_name) { 358 358 Some(_) -> Ok(Nil) 359 - None -> 360 - Error(errors.data_validation( 361 - def_name <> ": required field '" <> field_name <> "' is missing", 362 - )) 359 + None -> { 360 + let message = case def_name { 361 + "" -> "required field '" <> field_name <> "' is missing" 362 + _ -> def_name <> ": required field '" <> field_name <> "' is missing" 363 + } 364 + Error(errors.data_validation(message)) 365 + } 363 366 } 364 367 }) 365 368 }
+199
test/end_to_end_test.gleam
··· 5 5 import gleeunit 6 6 import gleeunit/should 7 7 import honk 8 + import honk/errors 8 9 import honk/types.{DateTime, Uri} 9 10 10 11 pub fn main() { ··· 327 328 honk.validate([lexicon]) 328 329 |> should.be_ok 329 330 } 331 + 332 + // Test missing required field error message with full defs.main path 333 + pub fn validate_record_missing_required_field_message_test() { 334 + let lexicon = 335 + json.object([ 336 + #("lexicon", json.int(1)), 337 + #("id", json.string("com.example.post")), 338 + #( 339 + "defs", 340 + json.object([ 341 + #( 342 + "main", 343 + json.object([ 344 + #("type", json.string("record")), 345 + #("key", json.string("tid")), 346 + #( 347 + "record", 348 + json.object([ 349 + #("type", json.string("object")), 350 + #("required", json.array([json.string("title")], fn(x) { x })), 351 + #( 352 + "properties", 353 + json.object([ 354 + #( 355 + "title", 356 + json.object([#("type", json.string("string"))]), 357 + ), 358 + ]), 359 + ), 360 + ]), 361 + ), 362 + ]), 363 + ), 364 + ]), 365 + ), 366 + ]) 367 + 368 + let data = json.object([#("description", json.string("No title"))]) 369 + 370 + let assert Error(error) = 371 + honk.validate_record([lexicon], "com.example.post", data) 372 + 373 + let error_message = errors.to_string(error) 374 + error_message 375 + |> should.equal( 376 + "Data validation failed: defs.main: required field 'title' is missing", 377 + ) 378 + } 379 + 380 + // Test missing required field in nested object with full path 381 + pub fn validate_record_nested_missing_required_field_message_test() { 382 + let lexicon = 383 + json.object([ 384 + #("lexicon", json.int(1)), 385 + #("id", json.string("com.example.post")), 386 + #( 387 + "defs", 388 + json.object([ 389 + #( 390 + "main", 391 + json.object([ 392 + #("type", json.string("record")), 393 + #("key", json.string("tid")), 394 + #( 395 + "record", 396 + json.object([ 397 + #("type", json.string("object")), 398 + #( 399 + "properties", 400 + json.object([ 401 + #( 402 + "title", 403 + json.object([#("type", json.string("string"))]), 404 + ), 405 + #( 406 + "metadata", 407 + json.object([ 408 + #("type", json.string("object")), 409 + #( 410 + "required", 411 + json.array([json.string("author")], fn(x) { x }), 412 + ), 413 + #( 414 + "properties", 415 + json.object([ 416 + #( 417 + "author", 418 + json.object([#("type", json.string("string"))]), 419 + ), 420 + ]), 421 + ), 422 + ]), 423 + ), 424 + ]), 425 + ), 426 + ]), 427 + ), 428 + ]), 429 + ), 430 + ]), 431 + ), 432 + ]) 433 + 434 + let data = 435 + json.object([ 436 + #("title", json.string("My Post")), 437 + #("metadata", json.object([#("tags", json.string("tech"))])), 438 + ]) 439 + 440 + let assert Error(error) = 441 + honk.validate_record([lexicon], "com.example.post", data) 442 + 443 + let error_message = errors.to_string(error) 444 + error_message 445 + |> should.equal( 446 + "Data validation failed: defs.main.metadata: required field 'author' is missing", 447 + ) 448 + } 449 + 450 + // Test schema validation error for non-main definition includes correct path 451 + pub fn validate_schema_non_main_definition_error_test() { 452 + let lexicon = 453 + json.object([ 454 + #("lexicon", json.int(1)), 455 + #("id", json.string("com.example.test")), 456 + #( 457 + "defs", 458 + json.object([ 459 + #( 460 + "objectDef", 461 + json.object([ 462 + #("type", json.string("object")), 463 + #( 464 + "properties", 465 + json.object([ 466 + #( 467 + "fieldA", 468 + json.object([ 469 + #("type", json.string("string")), 470 + // Invalid: maxLength must be an integer, not a string 471 + #("maxLength", json.string("300")), 472 + ]), 473 + ), 474 + ]), 475 + ), 476 + ]), 477 + ), 478 + #( 479 + "recordDef", 480 + json.object([ 481 + #("type", json.string("record")), 482 + #("key", json.string("tid")), 483 + #( 484 + "record", 485 + json.object([ 486 + #("type", json.string("object")), 487 + #( 488 + "properties", 489 + json.object([ 490 + #( 491 + "fieldB", 492 + json.object([ 493 + #("type", json.string("ref")), 494 + // Invalid: missing required "ref" field for ref type 495 + ]), 496 + ), 497 + ]), 498 + ), 499 + ]), 500 + ), 501 + ]), 502 + ), 503 + ]), 504 + ), 505 + ]) 506 + 507 + let result = honk.validate([lexicon]) 508 + 509 + // Should have errors 510 + result |> should.be_error 511 + 512 + case result { 513 + Error(error_map) -> { 514 + // Get errors for this lexicon 515 + case dict.get(error_map, "com.example.test") { 516 + Ok(error_list) -> { 517 + // Should have exactly one error from the recordDef (ref missing 'ref' field) 518 + error_list 519 + |> should.equal([ 520 + "com.example.test#recordDef: .record.properties.fieldB: ref missing required 'ref' field", 521 + ]) 522 + } 523 + Error(_) -> should.fail() 524 + } 525 + } 526 + Ok(_) -> should.fail() 527 + } 528 + }
+88
test/integration_test.gleam
··· 1 1 import gleam/json 2 2 import gleeunit 3 3 import gleeunit/should 4 + import honk/errors 4 5 import honk/validation/context 5 6 import honk/validation/primary/record 6 7 ··· 230 231 let result = record.validate_schema(schema, ctx) 231 232 result |> should.be_ok 232 233 } 234 + 235 + // Test missing required field error message at record root level 236 + pub fn record_missing_required_field_message_test() { 237 + let schema = 238 + json.object([ 239 + #("type", json.string("record")), 240 + #("key", json.string("tid")), 241 + #( 242 + "record", 243 + json.object([ 244 + #("type", json.string("object")), 245 + #("required", json.array([json.string("title")], fn(x) { x })), 246 + #( 247 + "properties", 248 + json.object([ 249 + #("title", json.object([#("type", json.string("string"))])), 250 + ]), 251 + ), 252 + ]), 253 + ), 254 + ]) 255 + 256 + let data = json.object([#("description", json.string("No title"))]) 257 + 258 + let assert Ok(ctx) = context.builder() |> context.build 259 + let assert Error(error) = record.validate_data(data, schema, ctx) 260 + 261 + let error_message = errors.to_string(error) 262 + error_message 263 + |> should.equal("Data validation failed: required field 'title' is missing") 264 + } 265 + 266 + // Test missing required field error message in nested object 267 + pub fn record_nested_missing_required_field_message_test() { 268 + let schema = 269 + json.object([ 270 + #("type", json.string("record")), 271 + #("key", json.string("tid")), 272 + #( 273 + "record", 274 + json.object([ 275 + #("type", json.string("object")), 276 + #( 277 + "properties", 278 + json.object([ 279 + #("title", json.object([#("type", json.string("string"))])), 280 + #( 281 + "metadata", 282 + json.object([ 283 + #("type", json.string("object")), 284 + #( 285 + "required", 286 + json.array([json.string("author")], fn(x) { x }), 287 + ), 288 + #( 289 + "properties", 290 + json.object([ 291 + #( 292 + "author", 293 + json.object([#("type", json.string("string"))]), 294 + ), 295 + #("tags", json.object([#("type", json.string("string"))])), 296 + ]), 297 + ), 298 + ]), 299 + ), 300 + ]), 301 + ), 302 + ]), 303 + ), 304 + ]) 305 + 306 + let data = 307 + json.object([ 308 + #("title", json.string("My Post")), 309 + #("metadata", json.object([#("tags", json.string("tech"))])), 310 + ]) 311 + 312 + let assert Ok(ctx) = context.builder() |> context.build 313 + let assert Error(error) = record.validate_data(data, schema, ctx) 314 + 315 + let error_message = errors.to_string(error) 316 + error_message 317 + |> should.equal( 318 + "Data validation failed: metadata: required field 'author' is missing", 319 + ) 320 + }
+25
test/object_validator_test.gleam
··· 1 1 import gleam/json 2 2 import gleeunit 3 3 import gleeunit/should 4 + import honk/errors 4 5 import honk/validation/context 5 6 import honk/validation/field 6 7 ··· 74 75 let result = field.validate_object_data(data, schema, ctx) 75 76 result |> should.be_error 76 77 } 78 + 79 + // Test missing required field error message at root level (no path) 80 + pub fn missing_required_field_message_root_test() { 81 + let schema = 82 + json.object([ 83 + #("type", json.string("object")), 84 + #( 85 + "properties", 86 + json.object([ 87 + #("title", json.object([#("type", json.string("string"))])), 88 + ]), 89 + ), 90 + #("required", json.array([json.string("title")], fn(x) { x })), 91 + ]) 92 + 93 + let data = json.object([#("other", json.string("value"))]) 94 + 95 + let assert Ok(ctx) = context.builder() |> context.build 96 + let assert Error(error) = field.validate_object_data(data, schema, ctx) 97 + 98 + let error_message = errors.to_string(error) 99 + error_message 100 + |> should.equal("Data validation failed: required field 'title' is missing") 101 + }
+8 -6
test/params_validator_test.gleam
··· 436 436 437 437 let data = 438 438 json.object([ 439 - #("tags", json.array([json.string("foo"), json.string("bar")], fn(x) { 440 - x 441 - })), 439 + #( 440 + "tags", 441 + json.array([json.string("foo"), json.string("bar")], fn(x) { x }), 442 + ), 442 443 ]) 443 444 444 445 let assert Ok(c) = context.builder() |> context.build() ··· 561 562 // Array contains strings instead of integers 562 563 let data = 563 564 json.object([ 564 - #("ids", json.array([json.string("one"), json.string("two")], fn(x) { 565 - x 566 - })), 565 + #( 566 + "ids", 567 + json.array([json.string("one"), json.string("two")], fn(x) { x }), 568 + ), 567 569 ]) 568 570 569 571 let assert Ok(c) = context.builder() |> context.build()