Something went wrong. Try again.
An ATProto Lexicon validator for Gleam.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570// Main public API for the ATProtocol lexicon validator
@target(erlang)import argvimport gleam/dict.{type Dict}import gleam/dynamicimport gleam/dynamic/decode@target(erlang)import gleam/int@target(erlang)import gleam/ioimport gleam/json.{type Json}import gleam/listimport gleam/option.{None, Some}import gleam/resultimport gleam/stringimport honk/errorsimport honk/internal/json_helpersimport honk/typesimport honk/validation/contextimport honk/validation/formats@target(erlang)import simplifile
// Import validatorsimport honk/validation/field as validation_fieldimport honk/validation/field/reference as validation_field_referenceimport honk/validation/field/union as validation_field_unionimport honk/validation/meta/token as validation_meta_tokenimport honk/validation/meta/unknown as validation_meta_unknownimport honk/validation/primary/params as validation_primary_paramsimport honk/validation/primary/procedure as validation_primary_procedureimport honk/validation/primary/query as validation_primary_queryimport honk/validation/primary/record as validation_primary_recordimport honk/validation/primary/subscription as validation_primary_subscriptionimport honk/validation/primitive/blob as validation_primitive_blobimport honk/validation/primitive/boolean as validation_primitive_booleanimport honk/validation/primitive/bytes as validation_primitive_bytesimport honk/validation/primitive/cid_link as validation_primitive_cid_linkimport honk/validation/primitive/integer as validation_primitive_integerimport honk/validation/primitive/null as validation_primitive_nullimport honk/validation/primitive/string as validation_primitive_string
// Re-export error type for public API error handlingpub type ValidationError = errors.ValidationError
/// Validates lexicon documents////// Validates lexicon structure (id, defs) and ALL definitions within each lexicon./// Each definition in the defs object is validated according to its type.////// Returns Ok(Nil) if all lexicons and their definitions are valid./// Returns Error with a map of lexicon ID to list of error messages./// Error messages include the definition name (e.g., "lex.id#defName: error").pub fn validate(lexicons: List(Json)) -> Result(Nil, Dict(String, List(String))) { // Build validation context let builder_result = context.builder() |> context.with_lexicons(lexicons)
case builder_result { Ok(builder) -> case context.build(builder) { Ok(ctx) -> { // Validate ALL definitions in each lexicon let error_map = dict.fold(ctx.lexicons, dict.new(), fn(errors, lex_id, lexicon) { // Get all definition names from the defs object let def_keys = json_helpers.get_keys(lexicon.defs) let lex_ctx = context.with_current_lexicon(ctx, lex_id)
// Validate each definition list.fold(def_keys, errors, fn(errors_acc, def_name) { case json_helpers.get_field(lexicon.defs, def_name) { Some(def) -> { case validate_definition(def, lex_ctx) { Ok(_) -> errors_acc Error(e) -> { // Include def name in error for better context // Extract just the message without wrapper text let message = case e { errors.InvalidSchema(msg) -> msg errors.DataValidation(msg) -> msg errors.LexiconNotFound(msg) -> "Lexicon not found: " <> msg } // Clean up leading ": " if present let clean_message = case string.starts_with(message, ": ") { True -> string.drop_start(message, 2) False -> message } let error_msg = lex_id <> "#" <> def_name <> ": " <> clean_message case dict.get(errors_acc, lex_id) { Ok(existing_errors) -> dict.insert(errors_acc, lex_id, [ error_msg, ..existing_errors ]) Error(_) -> dict.insert(errors_acc, lex_id, [error_msg]) } } } } None -> errors_acc } }) })
case dict.is_empty(error_map) { True -> Ok(Nil) False -> Error(error_map) } } Error(e) -> Error(dict.from_list([#("builder", [errors.to_string(e)])])) } Error(e) -> Error(dict.from_list([#("builder", [errors.to_string(e)])])) }}
/// Validates a single definition based on its typefn validate_definition( def: Json, ctx: context.ValidationContext,) -> Result(Nil, errors.ValidationError) { case json_helpers.get_string(def, "type") { Some("record") -> validation_primary_record.validate_schema(def, ctx) Some("query") -> validation_primary_query.validate_schema(def, ctx) Some("procedure") -> validation_primary_procedure.validate_schema(def, ctx) Some("subscription") -> validation_primary_subscription.validate_schema(def, ctx) Some("params") -> validation_primary_params.validate_schema(def, ctx) Some("object") -> validation_field.validate_object_schema(def, ctx) Some("array") -> validation_field.validate_array_schema(def, ctx) Some("union") -> validation_field_union.validate_schema(def, ctx) Some("string") -> validation_primitive_string.validate_schema(def, ctx) Some("integer") -> validation_primitive_integer.validate_schema(def, ctx) Some("boolean") -> validation_primitive_boolean.validate_schema(def, ctx) Some("bytes") -> validation_primitive_bytes.validate_schema(def, ctx) Some("blob") -> validation_primitive_blob.validate_schema(def, ctx) Some("cid-link") -> validation_primitive_cid_link.validate_schema(def, ctx) Some("null") -> validation_primitive_null.validate_schema(def, ctx) Some("ref") -> validation_field_reference.validate_schema(def, ctx) Some("token") -> validation_meta_token.validate_schema(def, ctx) Some("unknown") -> validation_meta_unknown.validate_schema(def, ctx) Some(unknown_type) -> Error(errors.invalid_schema("Unknown type: " <> unknown_type)) None -> Error(errors.invalid_schema("Definition missing type field")) }}
/// Validation context type (re-exported for external use)pub type ValidationContext = context.ValidationContext
/// Build a reusable validation context from lexicons/// Call this once, then use validate_record_with_context for each recordpub fn build_validation_context( lexicons: List(Json),) -> Result(ValidationContext, ValidationError) { let builder_result = context.builder() |> context.with_lexicons(lexicons)
use builder <- result.try(builder_result) context.build(builder)}
/// Validates a single data record against a collection schema using pre-built context/// This is much faster when validating many records - build context once with/// build_validation_context, then call this for each recordpub fn validate_record_with_context( ctx: ValidationContext, collection: String, record: Json,) -> Result(Nil, ValidationError) { // Get the lexicon for this collection case context.get_lexicon(ctx, collection) { Some(lexicon) -> { // Get the main definition (should be a record type) case json_helpers.get_field(lexicon.defs, "main") { Some(main_def) -> { let lex_ctx = context.with_current_lexicon(ctx, collection) // Set the path to include the definition name let def_ctx = context.with_path(lex_ctx, "defs.main") // Validate the record data against the main definition validation_primary_record.validate_data(record, main_def, def_ctx) } None -> Error(errors.invalid_schema( "Lexicon '" <> collection <> "' has no main definition", )) } } None -> Error(errors.lexicon_not_found(collection)) }}
/// Validates a single data record against a collection schemapub fn validate_record( lexicons: List(Json), collection: String, record: Json,) -> Result(Nil, ValidationError) { // Build validation context use ctx <- result.try(build_validation_context(lexicons)) validate_record_with_context(ctx, collection, record)}
/// Validates NSID formatpub fn is_valid_nsid(nsid: String) -> Bool { formats.is_valid_nsid(nsid)}
/// Validates a string value against a specific formatpub fn validate_string_format( value: String, format: types.StringFormat,) -> Result(Nil, String) { case formats.validate_format(value, format) { True -> Ok(Nil) False -> { let format_name = types.format_to_string(format) Error("Value does not match format: " <> format_name) } }}
/// Convert a Dynamic value to Json////// This is useful when parsing JSON strings with `json.parse(str, decode.dynamic)`/// and then needing to convert to Json for validation.////// ## Example/// ```gleam/// use dyn <- result.try(json.parse(json_str, decode.dynamic))/// use json_val <- result.try(honk.dynamic_to_json(dyn))/// honk.validate([json_val])/// ```pub fn dynamic_to_json(dyn: dynamic.Dynamic) -> Result(Json, ValidationError) { json_helpers.dynamic_to_json(dyn)}
/// Parse a JSON string and convert to Json for validation////// This is a convenience function that combines `json.parse()` and `dynamic_to_json()`./// It's useful when you have JSON stored as strings (e.g., in a database) and want/// to validate it with honk.////// ## Example/// ```gleam/// use json_val <- result.try(honk.parse_json_string(stored_json))/// honk.validate([json_val])/// ```pub fn parse_json_string(json_str: String) -> Result(Json, ValidationError) { use dyn <- result.try( json.parse(json_str, decode.dynamic) |> result.map_error(fn(_) { errors.invalid_schema("Failed to parse JSON string") }), ) dynamic_to_json(dyn)}
/// Parse multiple JSON strings and convert to Json for validation////// This is a convenience function for batch parsing JSON strings.////// ## Example/// ```gleam/// use json_vals <- result.try(honk.parse_json_strings(stored_jsons))/// honk.validate(json_vals)/// ```pub fn parse_json_strings( json_strs: List(String),) -> Result(List(Json), ValidationError) { json_strs |> list.try_map(parse_json_string) |> result.map_error(fn(_) { errors.invalid_schema("Failed to parse JSON strings") })}
@target(erlang)/// CLI entry point for the honk lexicon validator////// Usage:/// gleam run -m honk check <path>/// gleam run -m honk helppub fn main() -> Nil { case argv.load().arguments { ["check", path] -> validate_path(path) ["help"] | [] -> show_help() _ -> { io.println_error("Unknown command. Use 'help' for usage information.") Nil } }}
@target(erlang)/// Validate a path (auto-detects file or directory)fn validate_path(path: String) -> Nil { case simplifile.is_file(path) { Ok(True) -> validate_file(path) Ok(False) -> case simplifile.is_directory(path) { Ok(True) -> validate_directory(path) Ok(False) -> { io.println_error( "Error: Path is neither a file nor a directory: " <> path, ) Nil } Error(_) -> { io.println_error("Error: Cannot access path: " <> path) Nil } } Error(_) -> { io.println_error("Error: Cannot access path: " <> path) Nil } }}
@target(erlang)/// Validate a single lexicon filefn validate_file(file_path: String) -> Nil { case read_and_validate_file(file_path) { Ok(_) -> { io.println("✓ " <> file_path <> " - valid") Nil } Error(msg) -> { io.println_error("✗ " <> file_path) io.println_error(" " <> msg) Nil } }}
@target(erlang)/// Validate all .json files in a directoryfn validate_directory(dir_path: String) -> Nil { case simplifile.get_files(dir_path) { Error(_) -> { io.println_error("Error: Cannot read directory: " <> dir_path) Nil } Ok(all_files) -> { // Filter for .json files let json_files = all_files |> list.filter(fn(path) { string.ends_with(path, ".json") })
case json_files { [] -> { io.println("No .json files found in " <> dir_path) Nil } files -> { // Read and parse all files let file_results = files |> list.map(fn(file) { case read_json_file(file) { Ok(json_value) -> #(file, Ok(json_value)) Error(msg) -> #(file, Error(msg)) } })
// Separate successful parses from failures let #(parse_errors, parsed_files) = list.partition(file_results, fn(result) { case result { #(_, Error(_)) -> True #(_, Ok(_)) -> False } })
// Display parse errors parse_errors |> list.each(fn(result) { case result { #(file, Error(msg)) -> { io.println_error("✗ " <> file) io.println_error(" " <> msg) } _ -> Nil } })
// Get all successfully parsed lexicons let lexicons = parsed_files |> list.filter_map(fn(result) { case result { #(_, Ok(json)) -> Ok(json) _ -> Error(Nil) } })
// Validate all lexicons together (allows cross-lexicon references) case validate(lexicons) { Ok(_) -> { // All lexicons are valid parsed_files |> list.each(fn(result) { case result { #(file, Ok(_)) -> io.println("✓ " <> file) _ -> Nil } }) } Error(error_map) -> { // Some lexicons have errors - map errors back to files parsed_files |> list.each(fn(result) { case result { #(file, Ok(json)) -> { // Get the lexicon ID for this file case json_helpers.get_string(json, "id") { Some(lex_id) -> { case dict.get(error_map, lex_id) { Ok(errors) -> { io.println_error("✗ " <> file) errors |> list.each(fn(err) { io.println_error(" " <> err) }) } Error(_) -> io.println("✓ " <> file) } } None -> { io.println_error("✗ " <> file) io.println_error(" Missing lexicon id") } } } _ -> Nil } }) } }
// Summary let total = list.length(files) let parse_error_count = list.length(parse_errors) let validation_error_count = case validate(lexicons) { Ok(_) -> 0 Error(error_map) -> dict.size(error_map) } let total_errors = parse_error_count + validation_error_count
case total_errors { 0 -> io.println( "\nAll " <> int.to_string(total) <> " schemas validated successfully.", ) _ -> io.println_error( "\n" <> int.to_string(total_errors) <> " of " <> int.to_string(total) <> " schemas failed validation.", ) }
Nil } } } }}
@target(erlang)/// Read and parse a JSON file (without validation)fn read_json_file(file_path: String) -> Result(Json, String) { use content <- result.try( simplifile.read(file_path) |> result.map_error(fn(_) { "Cannot read file" }), )
use json_dynamic <- result.try( json.parse(content, decode.dynamic) |> result.map_error(fn(_) { "Invalid JSON" }), )
json_helpers.dynamic_to_json(json_dynamic) |> result.map_error(fn(_) { "Failed to convert JSON" })}
@target(erlang)/// Read a file and validate it as a lexiconfn read_and_validate_file(file_path: String) -> Result(Nil, String) { use content <- result.try( simplifile.read(file_path) |> result.map_error(fn(_) { "Cannot read file" }), )
use json_dynamic <- result.try( json.parse(content, decode.dynamic) |> result.map_error(fn(_) { "Invalid JSON" }), )
use json_value <- result.try( json_helpers.dynamic_to_json(json_dynamic) |> result.map_error(fn(_) { "Failed to convert JSON" }), )
use _ <- result.try( validate([json_value]) |> result.map_error(fn(error_map) { format_validation_errors(error_map) }), )
Ok(Nil)}
@target(erlang)/// Format validation errors from the error mapfn format_validation_errors(error_map: Dict(String, List(String))) -> String { error_map |> dict.to_list |> list.map(fn(entry) { let #(_key, errors) = entry string.join(errors, "\n ") }) |> string.join("\n ")}
@target(erlang)/// Show help textfn show_help() -> Nil { io.println( "honk - ATProtocol Lexicon Validator
USAGE: gleam run -m honk check <path> gleam run -m honk help
COMMANDS: check <path> Check a lexicon file or directory - If <path> is a file: validates that single lexicon - If <path> is a directory: recursively validates all .json files
help Show this help message
EXAMPLES: gleam run -m honk check ./lexicons/xyz/statusphere/status.json gleam run -m honk check ./lexicons
VALIDATION: - Validates lexicon structure (id, defs) - Validates ALL definitions in each lexicon - Checks types, constraints, and references - Reports errors with definition context (lex.id#defName)", )}