An ATProto Lexicon validator for Gleam.

update validator to validate all defs not just main

Changed files
+166 -15
src
test
+41 -15
src/honk.gleam
··· 2 2 3 3 import gleam/dict.{type Dict} 4 4 import gleam/json.{type Json} 5 + import gleam/list 5 6 import gleam/option.{None, Some} 6 7 import gleam/result 7 8 import honk/errors ··· 33 34 pub type ValidationError = 34 35 errors.ValidationError 35 36 36 - /// Main validation function for lexicon documents 37 - /// Returns Ok(Nil) if all lexicons are valid 38 - /// Returns Error with a map of lexicon ID to list of error messages 37 + /// Validates lexicon documents 38 + /// 39 + /// Validates lexicon structure (id, defs) and ALL definitions within each lexicon. 40 + /// Each definition in the defs object is validated according to its type. 41 + /// 42 + /// Returns Ok(Nil) if all lexicons and their definitions are valid. 43 + /// Returns Error with a map of lexicon ID to list of error messages. 44 + /// Error messages include the definition name (e.g., "lex.id#defName: error"). 39 45 pub fn validate(lexicons: List(Json)) -> Result(Nil, Dict(String, List(String))) { 40 46 // Build validation context 41 47 let builder_result = ··· 46 52 Ok(builder) -> 47 53 case context.build(builder) { 48 54 Ok(ctx) -> { 49 - // Validate each lexicon's main definition 55 + // Validate ALL definitions in each lexicon 50 56 let error_map = 51 57 dict.fold(ctx.lexicons, dict.new(), fn(errors, lex_id, lexicon) { 52 - // Validate the main definition if it exists 53 - case json_helpers.get_field(lexicon.defs, "main") { 54 - Some(main_def) -> { 55 - let lex_ctx = context.with_current_lexicon(ctx, lex_id) 56 - case validate_definition(main_def, lex_ctx) { 57 - Ok(_) -> errors 58 - Error(e) -> 59 - dict.insert(errors, lex_id, [errors.to_string(e)]) 58 + // Get all definition names from the defs object 59 + let def_keys = json_helpers.get_keys(lexicon.defs) 60 + let lex_ctx = context.with_current_lexicon(ctx, lex_id) 61 + 62 + // Validate each definition 63 + list.fold(def_keys, errors, fn(errors_acc, def_name) { 64 + case json_helpers.get_field(lexicon.defs, def_name) { 65 + Some(def) -> { 66 + case validate_definition(def, lex_ctx) { 67 + Ok(_) -> errors_acc 68 + Error(e) -> { 69 + // Include def name in error for better context 70 + let error_msg = 71 + lex_id 72 + <> "#" 73 + <> def_name 74 + <> ": " 75 + <> errors.to_string(e) 76 + case dict.get(errors_acc, lex_id) { 77 + Ok(existing_errors) -> 78 + dict.insert(errors_acc, lex_id, [ 79 + error_msg, 80 + ..existing_errors 81 + ]) 82 + Error(_) -> 83 + dict.insert(errors_acc, lex_id, [error_msg]) 84 + } 85 + } 86 + } 60 87 } 88 + None -> errors_acc 61 89 } 62 - None -> errors 63 - // No main definition is OK 64 - } 90 + }) 65 91 }) 66 92 67 93 case dict.is_empty(error_map) {
+125
test/end_to_end_test.gleam
··· 1 + import gleam/dict 1 2 import gleam/json 3 + import gleam/list 4 + import gleam/string 2 5 import gleeunit 3 6 import gleeunit/should 4 7 import honk ··· 202 205 honk.validate_string_format("https://example.com", Uri) 203 206 |> should.be_ok 204 207 } 208 + 209 + // Test lexicon with multiple valid definitions 210 + pub fn validate_lexicon_multiple_defs_test() { 211 + let lexicon = 212 + json.object([ 213 + #("lexicon", json.int(1)), 214 + #("id", json.string("com.example.multi")), 215 + #( 216 + "defs", 217 + json.object([ 218 + #( 219 + "main", 220 + json.object([ 221 + #("type", json.string("record")), 222 + #("key", json.string("tid")), 223 + #( 224 + "record", 225 + json.object([ 226 + #("type", json.string("object")), 227 + #("properties", json.object([])), 228 + ]), 229 + ), 230 + ]), 231 + ), 232 + #( 233 + "stringFormats", 234 + json.object([ 235 + #("type", json.string("object")), 236 + #("properties", json.object([])), 237 + ]), 238 + ), 239 + #("additionalType", json.object([#("type", json.string("string"))])), 240 + ]), 241 + ), 242 + ]) 243 + 244 + honk.validate([lexicon]) 245 + |> should.be_ok 246 + } 247 + 248 + // Test lexicon with only non-main definitions 249 + pub fn validate_lexicon_no_main_def_test() { 250 + let lexicon = 251 + json.object([ 252 + #("lexicon", json.int(1)), 253 + #("id", json.string("com.example.nomain")), 254 + #( 255 + "defs", 256 + json.object([ 257 + #("customType", json.object([#("type", json.string("string"))])), 258 + #("anotherType", json.object([#("type", json.string("integer"))])), 259 + ]), 260 + ), 261 + ]) 262 + 263 + honk.validate([lexicon]) 264 + |> should.be_ok 265 + } 266 + 267 + // Test lexicon with invalid non-main definition 268 + pub fn validate_lexicon_invalid_non_main_def_test() { 269 + let lexicon = 270 + json.object([ 271 + #("lexicon", json.int(1)), 272 + #("id", json.string("com.example.invalid")), 273 + #( 274 + "defs", 275 + json.object([ 276 + #( 277 + "main", 278 + json.object([ 279 + #("type", json.string("record")), 280 + #("key", json.string("tid")), 281 + #( 282 + "record", 283 + json.object([ 284 + #("type", json.string("object")), 285 + #("properties", json.object([])), 286 + ]), 287 + ), 288 + ]), 289 + ), 290 + #( 291 + "badDef", 292 + json.object([ 293 + #("type", json.string("string")), 294 + #("minLength", json.int(10)), 295 + #("maxLength", json.int(5)), 296 + ]), 297 + ), 298 + ]), 299 + ), 300 + ]) 301 + 302 + case honk.validate([lexicon]) { 303 + Error(error_map) -> { 304 + // Should have error for this lexicon 305 + case dict.get(error_map, "com.example.invalid") { 306 + Ok(errors) -> { 307 + // Error message should include the def name 308 + list.any(errors, fn(msg) { string.contains(msg, "#badDef") }) 309 + |> should.be_true 310 + } 311 + Error(_) -> panic as "Expected error for com.example.invalid" 312 + } 313 + } 314 + Ok(_) -> panic as "Expected validation to fail" 315 + } 316 + } 317 + 318 + // Test empty defs object 319 + pub fn validate_lexicon_empty_defs_test() { 320 + let lexicon = 321 + json.object([ 322 + #("lexicon", json.int(1)), 323 + #("id", json.string("com.example.empty")), 324 + #("defs", json.object([])), 325 + ]) 326 + 327 + honk.validate([lexicon]) 328 + |> should.be_ok 329 + }