/** * This file is part of Haketilo. * * Function: Powerful, full-blown format enforcer for externally-obtained JSON. * * Copyright (C) 2021 Wojtek Kosior * Redistribution terms are gathered in the `copyright' file. */ var error_path; var invalid_schema; function parse_json_with_schema(schema, json_string) { error_path = []; invalid_schema = false; try { return sanitize_unknown(schema, JSON.parse(json_string)); } catch (e) { throw `Invalid JSON${invalid_schema ? " schema" : ""}: ${e}.`; } finally { /* Allow garbage collection. */ error_path = undefined; } } function error_message(cause) { return `object${error_path.join("")} ${cause}`; } function sanitize_unknown(schema, item) { let error_msg = undefined; let schema_options = []; let has_default = false; let _default = undefined; if (!Array.isArray(schema) || schema[1] === "matchentry" || schema.length < 2 || !["ordefault", "or"].includes(schema[1])) return sanitize_unknown_no_alternatives(schema, item); if ((schema.length & 1) !== 1) { invalid_schema = true; throw error_message("was not understood"); } for (let i = 0; i < schema.length; i++) { if ((i & 1) !== 1) { schema_options.push(schema[i]); continue; } if (schema[i] === "or") continue; if (schema[i] === "ordefault" && schema.length === i + 2) { has_default = true; _default = schema[i + 1]; break; } invalid_schema = true; throw error_message("was not understood"); } for (const schema_option of schema_options) { try { return sanitize_unknown_no_alternatives(schema_option, item); } catch (e) { if (invalid_schema) throw e; if (has_default) continue; if (error_msg === undefined) error_msg = e; else error_msg = `${error_msg}, or ${e}`; } } if (has_default) return _default; throw error_msg; } function sanitize_unknown_no_alternatives(schema, item) { for (const [schema_check, item_check, sanitizer, type_name] of checks) { if (schema_check(schema)) { if (item_check(item)) return sanitizer(schema, item); throw error_message(`should be ${type_name} but is not`); } } invalid_schema = true; throw error_message("was not understood"); } function key_error_path_segment(key) { return /^[a-zA-Z_][a-zA-Z_0-9]*$/.exec(key) ? `.${key}` : `[${JSON.stringify(key)}]`; } /* * Generic object - one that can contain arbitrary keys (in addition to ones * specified explicitly in the schema). */ function sanitize_genobj(schema, object) { let max_matched_entries = Infinity; let min_matched_entries = 0; let matched_entries = 0; const entry_schemas = []; schema = [...schema]; if (schema[2] === "minentries") { if (schema.length < 4) { invalid_schema = true; throw error_message("was not understood"); } min_matched_entries = schema[3]; schema.splice(2, 2); } if (min_matched_entries < 0) { invalid_schema = true; throw error_message('specifies invalid "minentries" (should be a non-negative number)'); } if (schema[2] === "maxentries") { if (schema.length < 4) { invalid_schema = true; throw error_message("was not understood"); } max_matched_entries = schema[3]; schema.splice(2, 2); } if (max_matched_entries < 0) { invalid_schema = true; throw error_message('specifies invalid "maxentries" (should be a non-negative number)'); } while (schema.length > 2) { let regex = /.+/; if (schema.length > 3) { regex = schema[2]; schema.splice(2, 1); } if (typeof regex === "string") regex = new RegExp(regex); entry_schemas.push([regex, schema[2]]); schema.splice(2, 1); } const result = sanitize_object(schema[0], object); for (const [key, entry] of Object.entries(object)) { if (result.hasOwnProperty(key)) continue; matched_entries += 1; if (matched_entries > max_matched_entries) throw error_message(`has more than ${max_matched_entries} matched entr${max_matched_entries === 1 ? "y" : "ies"}`); error_path.push(key_error_path_segment(key)); let match = false; for (const [key_regex, entry_schema] of entry_schemas) { if (!key_regex.exec(key)) continue; match = true; sanitize_object_entry(result, key, entry_schema, object); break; } if (!match) { const regex_list = entry_schemas.map(i => i[0]).join(", "); throw error_message(`does not match any of key regexes: [${regex_list}]`); } error_path.pop(); } if (matched_entries < min_matched_entries) throw error_message(`has less than ${min_matched_entries} matched entr${min_matched_entries === 1 ? "y" : "ies"}`); return result; } function sanitize_array(schema, array) { let min_length = 0; let max_length = Infinity; let repeat_length = 1; let i = 0; const result = []; schema = [...schema]; if (schema[schema.length - 2] === "maxlen") { max_length = schema[schema.length - 1]; schema.splice(schema.length - 2); } if (schema[schema.length - 2] === "minlen") { min_length = schema[schema.length - 1]; schema.splice(schema.length - 2); } if (["repeat", "repeatfull"].includes(schema[schema.length - 2])) repeat_length = schema.pop(); if (repeat_length < 1) { invalid_schema = true; throw error_message('specifies invalid "${schema[schema.length - 2]}" (should be number greater than 1)'); } if (["repeat", "repeatfull"].includes(schema[schema.length - 1])) { var repeat_directive = schema.pop(); var repeat = schema.splice(schema.length - repeat_length); } else if (schema.length !== array.length) { throw error_message(`does not have exactly ${schema.length} items`); } if (repeat_directive === "repeatfull" && (array.length - schema.length) % repeat_length !== 0) throw error_message(`does not contain a full number of item group repetitions`); if (array.length < min_length) throw error_message(`has less than ${min_length} element${min_length === 1 ? "" : "s"}`); if (array.length > max_length) throw error_message(`has more than ${max_length} element${max_length === 1 ? "" : "s"}`); for (const item of array) { if (i >= schema.length) { i = 0; schema = repeat; } error_path.push(`[${i}]`); const sanitized = sanitize_unknown(schema[i], item); if (sanitized !== discard) result.push(sanitized); error_path.pop(); i++; } return result; } function sanitize_regex(schema, string) { if (schema.test(string)) return string; throw error_message(`does not match regex ${schema}`); } const string_spec_regex = /^string(:(.*))?$/; function sanitize_string(schema, string) { const regex = string_spec_regex.exec(schema)[2]; if (regex === undefined) return string; return sanitize_regex(new RegExp(regex), string); } function sanitize_object(schema, object) { const result = {}; for (let [key, entry_schema] of Object.entries(schema)) { error_path.push(key_error_path_segment(key)); sanitize_object_entry(result, key, entry_schema, object); error_path.pop(); } return result; } function sanitize_object_entry(result, key, entry_schema, object) { let optional = false; let has_default = false; let _default = undefined; if (Array.isArray(entry_schema) && entry_schema.length > 1) { if (entry_schema[0] === "optional") { optional = true; entry_schema = [...entry_schema].splice(1); const idx_def = entry_schema.length - (entry_schema.length & 1) - 1; if (entry_schema[idx_def] === "default") { has_default = true; _default = entry_schema[idx_def + 1]; entry_schema.splice(idx_def); } else if ((entry_schema.length & 1) !== 1) { invalid_schema = true; throw error_message("was not understood"); } if (entry_schema.length < 2) entry_schema = entry_schema[0]; } } let unsanitized_value = object[key]; if (unsanitized_value === undefined) { if (!optional) throw error_message("is missing"); if (has_default) result[key] = _default; return; } const sanitized = sanitize_unknown(entry_schema, unsanitized_value); if (sanitized !== discard) result[key] = sanitized; } function take_literal(schema, item) { return item; } /* * This function is used like a symbol. Other parts of code do sth like * `item === discard` to check if item was returned by this function. */ function discard(schema, item) { return discard; } /* * The following are some helper functions to categorize various * schema item specifiers (used in the array below). */ function is_genobj_spec(item) { return Array.isArray(item) && item[1] === "matchentry"; } function is_regex(item) { return typeof item === "object" && typeof item.test === "function"; } function is_string_spec(item) { return typeof item === "string" && string_spec_regex.test(item); } function is_object(item) { return typeof item === "object"; } function eq(what) { return i => i === what; } /* Array and null checks must go before object check. */ const checks = [ [is_genobj_spec, is_object, sanitize_genobj, "an object"], [Array.isArray, Array.isArray, sanitize_array, "an array"], [eq(null), i => i === null, take_literal, "null"], [is_regex, i => typeof i === "string", sanitize_regex, "a string"], [is_string_spec, i => typeof i === "string", sanitize_string, "a string"], [is_object, is_object, sanitize_object, "an object"], [eq("number"), i => typeof i === "number", take_literal, "a number"], [eq("boolean"), i => typeof i === "boolean", take_literal, "a boolean"], [eq("anything"), i => true, take_literal, "dummy"], [eq("discard"), i => true, discard, "dummy"] ]; /* * EXPORTS_START * EXPORT parse_json_with_schema * EXPORTS_END */