An ATProto Lexicon validator for Gleam.
1import gleam/dict
2import gleam/json
3import gleam/list
4import gleam/string
5import gleeunit
6import gleeunit/should
7import honk
8import honk/errors
9import honk/types.{DateTime, Uri}
10
11pub fn main() {
12 gleeunit.main()
13}
14
15// Test complete lexicon validation
16pub fn validate_complete_lexicon_test() {
17 // Create a complete lexicon for a blog post record
18 let lexicon =
19 json.object([
20 #("lexicon", json.int(1)),
21 #("id", json.string("app.bsky.feed.post")),
22 #(
23 "defs",
24 json.object([
25 #(
26 "main",
27 json.object([
28 #("type", json.string("record")),
29 #("key", json.string("tid")),
30 #(
31 "record",
32 json.object([
33 #("type", json.string("object")),
34 #("required", json.array([json.string("text")], fn(x) { x })),
35 #(
36 "properties",
37 json.object([
38 #(
39 "text",
40 json.object([
41 #("type", json.string("string")),
42 #("maxLength", json.int(300)),
43 #("maxGraphemes", json.int(300)),
44 ]),
45 ),
46 #(
47 "createdAt",
48 json.object([
49 #("type", json.string("string")),
50 #("format", json.string("datetime")),
51 ]),
52 ),
53 ]),
54 ),
55 ]),
56 ),
57 ]),
58 ),
59 ]),
60 ),
61 ])
62
63 let result = honk.validate([lexicon])
64 result |> should.be_ok
65}
66
67// Test invalid lexicon (missing id)
68pub fn validate_invalid_lexicon_missing_id_test() {
69 let lexicon =
70 json.object([
71 #("lexicon", json.int(1)),
72 #(
73 "defs",
74 json.object([
75 #(
76 "main",
77 json.object([
78 #("type", json.string("record")),
79 #("key", json.string("tid")),
80 #(
81 "record",
82 json.object([
83 #("type", json.string("object")),
84 #("properties", json.object([])),
85 ]),
86 ),
87 ]),
88 ),
89 ]),
90 ),
91 ])
92
93 let result = honk.validate([lexicon])
94 result |> should.be_error
95}
96
97// Test validate_record with valid data
98pub fn validate_record_data_valid_test() {
99 let lexicon =
100 json.object([
101 #("lexicon", json.int(1)),
102 #("id", json.string("app.bsky.feed.post")),
103 #(
104 "defs",
105 json.object([
106 #(
107 "main",
108 json.object([
109 #("type", json.string("record")),
110 #("key", json.string("tid")),
111 #(
112 "record",
113 json.object([
114 #("type", json.string("object")),
115 #("required", json.array([json.string("text")], fn(x) { x })),
116 #(
117 "properties",
118 json.object([
119 #(
120 "text",
121 json.object([
122 #("type", json.string("string")),
123 #("maxLength", json.int(300)),
124 ]),
125 ),
126 ]),
127 ),
128 ]),
129 ),
130 ]),
131 ),
132 ]),
133 ),
134 ])
135
136 let record_data = json.object([#("text", json.string("Hello, ATProtocol!"))])
137
138 let result =
139 honk.validate_record([lexicon], "app.bsky.feed.post", record_data)
140 result |> should.be_ok
141}
142
143// Test validate_record with invalid data (missing required field)
144pub fn validate_record_data_missing_required_test() {
145 let lexicon =
146 json.object([
147 #("lexicon", json.int(1)),
148 #("id", json.string("app.bsky.feed.post")),
149 #(
150 "defs",
151 json.object([
152 #(
153 "main",
154 json.object([
155 #("type", json.string("record")),
156 #("key", json.string("tid")),
157 #(
158 "record",
159 json.object([
160 #("type", json.string("object")),
161 #("required", json.array([json.string("text")], fn(x) { x })),
162 #(
163 "properties",
164 json.object([
165 #(
166 "text",
167 json.object([
168 #("type", json.string("string")),
169 #("maxLength", json.int(300)),
170 ]),
171 ),
172 ]),
173 ),
174 ]),
175 ),
176 ]),
177 ),
178 ]),
179 ),
180 ])
181
182 let record_data =
183 json.object([#("description", json.string("No text field"))])
184
185 let result =
186 honk.validate_record([lexicon], "app.bsky.feed.post", record_data)
187 result |> should.be_error
188}
189
190// Test NSID validation helper
191pub fn is_valid_nsid_test() {
192 honk.is_valid_nsid("app.bsky.feed.post") |> should.be_true
193 honk.is_valid_nsid("com.example.foo") |> should.be_true
194 honk.is_valid_nsid("invalid") |> should.be_false
195 honk.is_valid_nsid("") |> should.be_false
196}
197
198// Test string format validation helper
199pub fn validate_string_format_test() {
200 honk.validate_string_format("2024-01-01T12:00:00Z", DateTime)
201 |> should.be_ok
202
203 honk.validate_string_format("not a datetime", DateTime)
204 |> should.be_error
205
206 honk.validate_string_format("https://example.com", Uri)
207 |> should.be_ok
208}
209
210// Test lexicon with multiple valid definitions
211pub fn validate_lexicon_multiple_defs_test() {
212 let lexicon =
213 json.object([
214 #("lexicon", json.int(1)),
215 #("id", json.string("com.example.multi")),
216 #(
217 "defs",
218 json.object([
219 #(
220 "main",
221 json.object([
222 #("type", json.string("record")),
223 #("key", json.string("tid")),
224 #(
225 "record",
226 json.object([
227 #("type", json.string("object")),
228 #("properties", json.object([])),
229 ]),
230 ),
231 ]),
232 ),
233 #(
234 "stringFormats",
235 json.object([
236 #("type", json.string("object")),
237 #("properties", json.object([])),
238 ]),
239 ),
240 #("additionalType", json.object([#("type", json.string("string"))])),
241 ]),
242 ),
243 ])
244
245 honk.validate([lexicon])
246 |> should.be_ok
247}
248
249// Test lexicon with only non-main definitions
250pub fn validate_lexicon_no_main_def_test() {
251 let lexicon =
252 json.object([
253 #("lexicon", json.int(1)),
254 #("id", json.string("com.example.nomain")),
255 #(
256 "defs",
257 json.object([
258 #("customType", json.object([#("type", json.string("string"))])),
259 #("anotherType", json.object([#("type", json.string("integer"))])),
260 ]),
261 ),
262 ])
263
264 honk.validate([lexicon])
265 |> should.be_ok
266}
267
268// Test lexicon with invalid non-main definition
269pub fn validate_lexicon_invalid_non_main_def_test() {
270 let lexicon =
271 json.object([
272 #("lexicon", json.int(1)),
273 #("id", json.string("com.example.invalid")),
274 #(
275 "defs",
276 json.object([
277 #(
278 "main",
279 json.object([
280 #("type", json.string("record")),
281 #("key", json.string("tid")),
282 #(
283 "record",
284 json.object([
285 #("type", json.string("object")),
286 #("properties", json.object([])),
287 ]),
288 ),
289 ]),
290 ),
291 #(
292 "badDef",
293 json.object([
294 #("type", json.string("string")),
295 #("minLength", json.int(10)),
296 #("maxLength", json.int(5)),
297 ]),
298 ),
299 ]),
300 ),
301 ])
302
303 case honk.validate([lexicon]) {
304 Error(error_map) -> {
305 // Should have error for this lexicon
306 case dict.get(error_map, "com.example.invalid") {
307 Ok(errors) -> {
308 // Error message should include the def name
309 list.any(errors, fn(msg) { string.contains(msg, "#badDef") })
310 |> should.be_true
311 }
312 Error(_) -> panic as "Expected error for com.example.invalid"
313 }
314 }
315 Ok(_) -> panic as "Expected validation to fail"
316 }
317}
318
319// Test empty defs object
320pub fn validate_lexicon_empty_defs_test() {
321 let lexicon =
322 json.object([
323 #("lexicon", json.int(1)),
324 #("id", json.string("com.example.empty")),
325 #("defs", json.object([])),
326 ])
327
328 honk.validate([lexicon])
329 |> should.be_ok
330}
331
332// Test missing required field error message with full defs.main path
333pub 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
381pub 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
451pub 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}