An ATProto Lexicon validator for Gleam.
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}