An ATProto Lexicon validator for Gleam.
at v1.2.0 21 kB view raw
1import gleam/json 2import gleeunit 3import gleeunit/should 4import honk/validation/context 5import honk/validation/field 6import honk/validation/field/union 7 8pub fn main() { 9 gleeunit.main() 10} 11 12// Test open union with empty refs 13pub fn open_union_empty_refs_test() { 14 let schema = 15 json.object([ 16 #("type", json.string("union")), 17 #("refs", json.array([], fn(x) { x })), 18 #("closed", json.bool(False)), 19 ]) 20 21 let assert Ok(ctx) = context.builder() |> context.build 22 let result = union.validate_schema(schema, ctx) 23 result |> should.be_ok 24} 25 26// Test closed union with empty refs (should fail) 27pub fn closed_union_empty_refs_test() { 28 let schema = 29 json.object([ 30 #("type", json.string("union")), 31 #("refs", json.array([], fn(x) { x })), 32 #("closed", json.bool(True)), 33 ]) 34 35 let assert Ok(ctx) = context.builder() |> context.build 36 let result = union.validate_schema(schema, ctx) 37 result |> should.be_error 38} 39 40// Test union missing refs field 41pub fn union_missing_refs_test() { 42 let schema = json.object([#("type", json.string("union"))]) 43 44 let assert Ok(ctx) = context.builder() |> context.build 45 let result = union.validate_schema(schema, ctx) 46 result |> should.be_error 47} 48 49// Test valid union data with $type matching global ref 50pub fn valid_union_data_test() { 51 let schema = 52 json.object([ 53 #("type", json.string("union")), 54 #("refs", json.array([json.string("com.example.post")], fn(x) { x })), 55 ]) 56 57 let data = 58 json.object([ 59 #("$type", json.string("com.example.post")), 60 #("text", json.string("Hello world")), 61 ]) 62 63 let assert Ok(ctx) = context.builder() |> context.build 64 let result = union.validate_data(data, schema, ctx) 65 result |> should.be_ok 66} 67 68// Test union data missing $type field 69pub fn union_data_missing_type_test() { 70 let schema = 71 json.object([ 72 #("type", json.string("union")), 73 #("refs", json.array([json.string("com.example.post")], fn(x) { x })), 74 ]) 75 76 let data = json.object([#("text", json.string("Hello"))]) 77 78 let assert Ok(ctx) = context.builder() |> context.build 79 let result = union.validate_data(data, schema, ctx) 80 result |> should.be_error 81} 82 83// Test union data with non-object value 84pub fn union_data_non_object_test() { 85 let schema = 86 json.object([ 87 #("type", json.string("union")), 88 #("refs", json.array([json.string("com.example.post")], fn(x) { x })), 89 ]) 90 91 let data = json.string("not an object") 92 93 let assert Ok(ctx) = context.builder() |> context.build 94 let result = union.validate_data(data, schema, ctx) 95 result |> should.be_error 96} 97 98// Test closed union rejects $type not in refs 99pub fn union_data_type_not_in_refs_test() { 100 let schema = 101 json.object([ 102 #("type", json.string("union")), 103 #("refs", json.array([json.string("com.example.typeA")], fn(x) { x })), 104 #("closed", json.bool(True)), 105 ]) 106 107 let data = 108 json.object([ 109 #("$type", json.string("com.example.typeB")), 110 #("data", json.string("some data")), 111 ]) 112 113 let assert Ok(ctx) = context.builder() |> context.build 114 let result = union.validate_data(data, schema, ctx) 115 result |> should.be_error 116} 117 118// Test union with invalid ref (non-string in array) 119pub fn union_with_invalid_ref_type_test() { 120 let schema = 121 json.object([ 122 #("type", json.string("union")), 123 #( 124 "refs", 125 json.array([json.int(123), json.string("com.example.post")], fn(x) { x }), 126 ), 127 ]) 128 129 let assert Ok(ctx) = context.builder() |> context.build 130 let result = union.validate_schema(schema, ctx) 131 result |> should.be_error 132} 133 134// Test local ref matching in data validation 135pub fn union_data_local_ref_matching_test() { 136 let schema = 137 json.object([ 138 #("type", json.string("union")), 139 #( 140 "refs", 141 json.array([json.string("#post"), json.string("#reply")], fn(x) { x }), 142 ), 143 ]) 144 145 // Data with $type matching local ref pattern 146 let data = 147 json.object([ 148 #("$type", json.string("post")), 149 #("text", json.string("Hello")), 150 ]) 151 152 let assert Ok(ctx) = context.builder() |> context.build 153 let result = union.validate_data(data, schema, ctx) 154 // Should pass because local ref #post matches bare name "post" 155 result |> should.be_ok 156} 157 158// Test local ref with NSID in data 159pub fn union_data_local_ref_with_nsid_test() { 160 let schema = 161 json.object([ 162 #("type", json.string("union")), 163 #("refs", json.array([json.string("#view")], fn(x) { x })), 164 ]) 165 166 // Data with $type as full NSID#fragment 167 let data = 168 json.object([ 169 #("$type", json.string("com.example.feed#view")), 170 #("uri", json.string("at://did:plc:abc/com.example.feed/123")), 171 ]) 172 173 let assert Ok(ctx) = context.builder() |> context.build 174 let result = union.validate_data(data, schema, ctx) 175 // Should pass because local ref #view matches NSID with #view fragment 176 result |> should.be_ok 177} 178 179// Test multiple local refs in schema 180pub fn union_with_multiple_local_refs_test() { 181 let schema = 182 json.object([ 183 #("type", json.string("union")), 184 #( 185 "refs", 186 json.array( 187 [json.string("#post"), json.string("#repost"), json.string("#reply")], 188 fn(x) { x }, 189 ), 190 ), 191 ]) 192 193 let assert Ok(ctx) = context.builder() |> context.build 194 let result = union.validate_schema(schema, ctx) 195 // In test context without lexicon catalog, local refs are syntactically valid 196 result |> should.be_ok 197} 198 199// Test mixed global and local refs 200pub fn union_with_mixed_refs_test() { 201 let schema = 202 json.object([ 203 #("type", json.string("union")), 204 #( 205 "refs", 206 json.array( 207 [json.string("com.example.post"), json.string("#localDef")], 208 fn(x) { x }, 209 ), 210 ), 211 ]) 212 213 let assert Ok(ctx) = context.builder() |> context.build 214 let result = union.validate_schema(schema, ctx) 215 // In test context without lexicon catalog, both types are syntactically valid 216 result |> should.be_ok 217} 218 219// Test all primitive types for non-object validation 220pub fn union_data_all_non_object_types_test() { 221 let schema = 222 json.object([ 223 #("type", json.string("union")), 224 #("refs", json.array([json.string("com.example.post")], fn(x) { x })), 225 ]) 226 227 let assert Ok(ctx) = context.builder() |> context.build 228 229 // Test number 230 let number_data = json.int(123) 231 union.validate_data(number_data, schema, ctx) |> should.be_error 232 233 // Test string 234 let string_data = json.string("not an object") 235 union.validate_data(string_data, schema, ctx) |> should.be_error 236 237 // Test null 238 let null_data = json.null() 239 union.validate_data(null_data, schema, ctx) |> should.be_error 240 241 // Test array 242 let array_data = json.array([json.string("item")], fn(x) { x }) 243 union.validate_data(array_data, schema, ctx) |> should.be_error 244 245 // Test boolean 246 let bool_data = json.bool(True) 247 union.validate_data(bool_data, schema, ctx) |> should.be_error 248} 249 250// Test empty refs in data validation context 251pub fn union_data_empty_refs_test() { 252 let schema = 253 json.object([ 254 #("type", json.string("union")), 255 #("refs", json.array([], fn(x) { x })), 256 ]) 257 258 let data = 259 json.object([ 260 #("$type", json.string("any.type")), 261 #("data", json.string("some data")), 262 ]) 263 264 let assert Ok(ctx) = context.builder() |> context.build 265 let result = union.validate_data(data, schema, ctx) 266 // Data validation should fail with empty refs array 267 result |> should.be_error 268} 269 270// Test comprehensive reference matching with full lexicon catalog 271pub fn union_data_reference_matching_test() { 272 // Set up lexicons with local, global main, and fragment refs 273 let main_lexicon = 274 json.object([ 275 #("lexicon", json.int(1)), 276 #("id", json.string("com.example.test")), 277 #( 278 "defs", 279 json.object([ 280 #( 281 "main", 282 json.object([ 283 #("type", json.string("union")), 284 #( 285 "refs", 286 json.array( 287 [ 288 json.string("#localType"), 289 json.string("com.example.global#main"), 290 json.string("com.example.types#fragmentType"), 291 ], 292 fn(x) { x }, 293 ), 294 ), 295 ]), 296 ), 297 #( 298 "localType", 299 json.object([ 300 #("type", json.string("object")), 301 #("properties", json.object([])), 302 ]), 303 ), 304 ]), 305 ), 306 ]) 307 308 let global_lexicon = 309 json.object([ 310 #("lexicon", json.int(1)), 311 #("id", json.string("com.example.global")), 312 #( 313 "defs", 314 json.object([ 315 #( 316 "main", 317 json.object([ 318 #("type", json.string("object")), 319 #("properties", json.object([])), 320 ]), 321 ), 322 ]), 323 ), 324 ]) 325 326 let types_lexicon = 327 json.object([ 328 #("lexicon", json.int(1)), 329 #("id", json.string("com.example.types")), 330 #( 331 "defs", 332 json.object([ 333 #( 334 "fragmentType", 335 json.object([ 336 #("type", json.string("object")), 337 #("properties", json.object([])), 338 ]), 339 ), 340 ]), 341 ), 342 ]) 343 344 let assert Ok(builder) = 345 context.builder() 346 |> context.with_validator(field.dispatch_data_validation) 347 |> context.with_lexicons([main_lexicon, global_lexicon, types_lexicon]) 348 349 let assert Ok(ctx) = builder |> context.build() 350 let ctx = context.with_current_lexicon(ctx, "com.example.test") 351 352 let schema = 353 json.object([ 354 #("type", json.string("union")), 355 #( 356 "refs", 357 json.array( 358 [ 359 json.string("#localType"), 360 json.string("com.example.global#main"), 361 json.string("com.example.types#fragmentType"), 362 ], 363 fn(x) { x }, 364 ), 365 ), 366 ]) 367 368 // Test local reference match 369 let local_data = json.object([#("$type", json.string("localType"))]) 370 union.validate_data(local_data, schema, ctx) |> should.be_ok 371 372 // Test global main reference match 373 let global_data = 374 json.object([#("$type", json.string("com.example.global#main"))]) 375 union.validate_data(global_data, schema, ctx) |> should.be_ok 376 377 // Test global fragment reference match 378 let fragment_data = 379 json.object([#("$type", json.string("com.example.types#fragmentType"))]) 380 union.validate_data(fragment_data, schema, ctx) |> should.be_ok 381} 382 383// Test full schema resolution with constraint validation 384pub fn union_data_with_schema_resolution_test() { 385 let main_lexicon = 386 json.object([ 387 #("lexicon", json.int(1)), 388 #("id", json.string("com.example.feed")), 389 #( 390 "defs", 391 json.object([ 392 #( 393 "main", 394 json.object([ 395 #("type", json.string("union")), 396 #( 397 "refs", 398 json.array( 399 [ 400 json.string("#post"), 401 json.string("#repost"), 402 json.string("com.example.types#like"), 403 ], 404 fn(x) { x }, 405 ), 406 ), 407 ]), 408 ), 409 #( 410 "post", 411 json.object([ 412 #("type", json.string("object")), 413 #( 414 "properties", 415 json.object([ 416 #( 417 "title", 418 json.object([ 419 #("type", json.string("string")), 420 #("maxLength", json.int(100)), 421 ]), 422 ), 423 #("content", json.object([#("type", json.string("string"))])), 424 ]), 425 ), 426 #("required", json.array([json.string("title")], fn(x) { x })), 427 ]), 428 ), 429 #( 430 "repost", 431 json.object([ 432 #("type", json.string("object")), 433 #( 434 "properties", 435 json.object([ 436 #("original", json.object([#("type", json.string("string"))])), 437 #("comment", json.object([#("type", json.string("string"))])), 438 ]), 439 ), 440 #("required", json.array([json.string("original")], fn(x) { x })), 441 ]), 442 ), 443 ]), 444 ), 445 ]) 446 447 let types_lexicon = 448 json.object([ 449 #("lexicon", json.int(1)), 450 #("id", json.string("com.example.types")), 451 #( 452 "defs", 453 json.object([ 454 #( 455 "like", 456 json.object([ 457 #("type", json.string("object")), 458 #( 459 "properties", 460 json.object([ 461 #("target", json.object([#("type", json.string("string"))])), 462 #( 463 "emoji", 464 json.object([ 465 #("type", json.string("string")), 466 #("maxLength", json.int(10)), 467 ]), 468 ), 469 ]), 470 ), 471 #("required", json.array([json.string("target")], fn(x) { x })), 472 ]), 473 ), 474 ]), 475 ), 476 ]) 477 478 let assert Ok(builder) = 479 context.builder() 480 |> context.with_validator(field.dispatch_data_validation) 481 |> context.with_lexicons([main_lexicon, types_lexicon]) 482 483 let assert Ok(ctx) = builder |> context.build() 484 let ctx = context.with_current_lexicon(ctx, "com.example.feed") 485 486 let union_schema = 487 json.object([ 488 #("type", json.string("union")), 489 #( 490 "refs", 491 json.array( 492 [ 493 json.string("#post"), 494 json.string("#repost"), 495 json.string("com.example.types#like"), 496 ], 497 fn(x) { x }, 498 ), 499 ), 500 ]) 501 502 // Test valid post data (with all required fields) 503 let valid_post = 504 json.object([ 505 #("$type", json.string("post")), 506 #("title", json.string("My Post")), 507 #("content", json.string("This is my post content")), 508 ]) 509 union.validate_data(valid_post, union_schema, ctx) |> should.be_ok 510 511 // Test invalid post data (missing required field) 512 let invalid_post = 513 json.object([ 514 #("$type", json.string("post")), 515 #("content", json.string("This is missing a title")), 516 ]) 517 union.validate_data(invalid_post, union_schema, ctx) |> should.be_error 518 519 // Test valid repost data (with all required fields) 520 let valid_repost = 521 json.object([ 522 #("$type", json.string("repost")), 523 #("original", json.string("original-post-uri")), 524 #("comment", json.string("Great post!")), 525 ]) 526 union.validate_data(valid_repost, union_schema, ctx) |> should.be_ok 527 528 // Test valid like data (global reference with all required fields) 529 let valid_like = 530 json.object([ 531 #("$type", json.string("com.example.types#like")), 532 #("target", json.string("post-uri")), 533 #("emoji", json.string("👍")), 534 ]) 535 union.validate_data(valid_like, union_schema, ctx) |> should.be_ok 536 537 // Test invalid like data (missing required field) 538 let invalid_like = 539 json.object([ 540 #("$type", json.string("com.example.types#like")), 541 #("emoji", json.string("👍")), 542 ]) 543 union.validate_data(invalid_like, union_schema, ctx) |> should.be_error 544} 545 546// Test open vs closed union comparison 547pub fn union_data_open_vs_closed_test() { 548 let lexicon = 549 json.object([ 550 #("lexicon", json.int(1)), 551 #("id", json.string("com.example.test")), 552 #( 553 "defs", 554 json.object([ 555 #( 556 "main", 557 json.object([ 558 #("type", json.string("union")), 559 #("refs", json.array([json.string("#post")], fn(x) { x })), 560 #("closed", json.bool(False)), 561 ]), 562 ), 563 #( 564 "post", 565 json.object([ 566 #("type", json.string("object")), 567 #( 568 "properties", 569 json.object([ 570 #("title", json.object([#("type", json.string("string"))])), 571 ]), 572 ), 573 ]), 574 ), 575 ]), 576 ), 577 ]) 578 579 let assert Ok(builder) = 580 context.builder() 581 |> context.with_validator(field.dispatch_data_validation) 582 |> context.with_lexicons([lexicon]) 583 let assert Ok(ctx) = builder |> context.build() 584 let ctx = context.with_current_lexicon(ctx, "com.example.test") 585 586 let open_union_schema = 587 json.object([ 588 #("type", json.string("union")), 589 #("refs", json.array([json.string("#post")], fn(x) { x })), 590 #("closed", json.bool(False)), 591 ]) 592 593 let closed_union_schema = 594 json.object([ 595 #("type", json.string("union")), 596 #("refs", json.array([json.string("#post")], fn(x) { x })), 597 #("closed", json.bool(True)), 598 ]) 599 600 // Known $type should work in both 601 let known_type = 602 json.object([ 603 #("$type", json.string("post")), 604 #("title", json.string("Test")), 605 ]) 606 union.validate_data(known_type, open_union_schema, ctx) |> should.be_ok 607 union.validate_data(known_type, closed_union_schema, ctx) |> should.be_ok 608 609 // Unknown $type - behavior differs between open/closed 610 let unknown_type = 611 json.object([ 612 #("$type", json.string("unknown_type")), 613 #("data", json.string("test")), 614 ]) 615 // Open union should accept unknown types 616 union.validate_data(unknown_type, open_union_schema, ctx) |> should.be_ok 617 // Closed union should reject unknown types 618 union.validate_data(unknown_type, closed_union_schema, ctx) |> should.be_error 619} 620 621// Test basic union with full lexicon context 622pub fn union_data_basic_with_full_context_test() { 623 let main_lexicon = 624 json.object([ 625 #("lexicon", json.int(1)), 626 #("id", json.string("com.example.test")), 627 #( 628 "defs", 629 json.object([ 630 #( 631 "main", 632 json.object([ 633 #("type", json.string("union")), 634 #( 635 "refs", 636 json.array( 637 [ 638 json.string("#post"), 639 json.string("#repost"), 640 json.string("com.example.like#main"), 641 ], 642 fn(x) { x }, 643 ), 644 ), 645 ]), 646 ), 647 #( 648 "post", 649 json.object([ 650 #("type", json.string("object")), 651 #( 652 "properties", 653 json.object([ 654 #("title", json.object([#("type", json.string("string"))])), 655 #("content", json.object([#("type", json.string("string"))])), 656 ]), 657 ), 658 ]), 659 ), 660 #( 661 "repost", 662 json.object([ 663 #("type", json.string("object")), 664 #( 665 "properties", 666 json.object([ 667 #("original", json.object([#("type", json.string("string"))])), 668 ]), 669 ), 670 ]), 671 ), 672 ]), 673 ), 674 ]) 675 676 let like_lexicon = 677 json.object([ 678 #("lexicon", json.int(1)), 679 #("id", json.string("com.example.like")), 680 #( 681 "defs", 682 json.object([ 683 #( 684 "main", 685 json.object([ 686 #("type", json.string("object")), 687 #( 688 "properties", 689 json.object([ 690 #("target", json.object([#("type", json.string("string"))])), 691 ]), 692 ), 693 ]), 694 ), 695 ]), 696 ), 697 ]) 698 699 let assert Ok(builder) = 700 context.builder() 701 |> context.with_validator(field.dispatch_data_validation) 702 |> context.with_lexicons([main_lexicon, like_lexicon]) 703 704 let assert Ok(ctx) = builder |> context.build() 705 let ctx = context.with_current_lexicon(ctx, "com.example.test") 706 707 let schema = 708 json.object([ 709 #("type", json.string("union")), 710 #( 711 "refs", 712 json.array( 713 [ 714 json.string("#post"), 715 json.string("#repost"), 716 json.string("com.example.like#main"), 717 ], 718 fn(x) { x }, 719 ), 720 ), 721 ]) 722 723 // Valid union data with local reference 724 let post_data = 725 json.object([ 726 #("$type", json.string("post")), 727 #("title", json.string("My Post")), 728 #("content", json.string("Post content")), 729 ]) 730 union.validate_data(post_data, schema, ctx) |> should.be_ok 731 732 // Valid union data with global reference 733 let like_data = 734 json.object([ 735 #("$type", json.string("com.example.like#main")), 736 #("target", json.string("some-target")), 737 ]) 738 union.validate_data(like_data, schema, ctx) |> should.be_ok 739}