An ATProto Lexicon validator for Gleam.
at main 15 kB view raw
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}