// Inbound schema validation, as GSF runs it on every event before any handler // sees it: GenesisMessageDecoder validates the whole message against the // event's JSON schema (the INBOUND that JSON_SCHEMA_REQUEST serves) and // answers each failure with a FieldError VALIDATION_ERROR. GSF 8.15.29 uses // networknt json-schema-validator 2.0.1, configured by // GenesisJsonSchemaFactory (draft 2019-09, PathType.LEGACY, // losslessNarrowing). This is the subset of that validator the engine's own // event schemas need: type, const, enum, oneOf, properties, required, // additionalProperties, minLength / maxLength, minimum / maximum, pattern, and // for a DTO's lists, sets and nested classes items, uniqueItems and $ref (into // the schema's own $defs). // Texts, locations and their order follow networknt's output, checked by // running the real validator on the recorded showcase schemas // (test/schemaValidation.test.ts pins the cases). // // Order: at each schema, `type` and then `properties` are checked first and // every other keyword after them in the schema's key order; `properties` in // their key order; unknown properties in the instance's order. A failed // `type` doesn't stop the rest (an enum still reports its own error). // `oneOf` with no valid branch reports itself, then every branch's errors. import type { NackErrorInput } from './errors.ts'; type Schema = Record; export interface SchemaError { keyword: string; // PathType.LEGACY: '$', '$.DETAILS', '$.DETAILS.NAME'. instanceLocation: string; // The missing property, for `required`; the unknown one, for // `additionalProperties`. property?: string; message: string; } // networknt's JsonType names. With losslessNarrowing a number with no // fraction is an integer (1.0 included, which JSON.parse makes 1 anyway). function jsonTypeOf(value: unknown): string { if (value === null) return 'null'; if (Array.isArray(value)) return 'array'; if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number'; return typeof value; } function matchesType(value: unknown, type: string): boolean { const actual = jsonTypeOf(value); return actual === type || (type === 'number' && actual === 'integer'); } const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((item, index) => deepEqual(item, b[index])); } if (isObject(a) && isObject(b)) { const keys = Object.keys(a); return ( keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key])) ); } return false; } // A JSON value written with its object keys sorted: the same text for two // values deepEqual calls equal, so a long array's repeats are found in one // pass. function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (isObject(value)) { const entries = Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`); return `{${entries.join(',')}}`; } return JSON.stringify(value) ?? 'undefined'; } // networknt evaluates these before a schema's other keywords, whatever their // position; the rest go in key order. const FIRST_KEYWORDS = ['type', 'properties']; // String lengths count code points, as Java's codePointCount does. const codePoints = (text: string) => [...text].length; interface KeywordCheck { arg: any; schema: Schema; // The schema validation started from, which $ref points into. root: Schema; value: unknown; location: string; errors: SchemaError[]; fail(message: string, property?: string): void; } const DEFS = '#/$defs/'; const NOT_DEFINED = 'is not defined in the schema and the schema does not allow additional properties'; // One check per keyword, each a no-op on values it doesn't apply to (string // keywords on non-strings, object keywords on non-objects, ...). const KEYWORDS: Record void> = { type({ arg, value, fail }) { if (typeof arg === 'string' && !matchesType(value, arg)) { fail(`${jsonTypeOf(value)} found, ${arg} expected`); } }, const({ arg, value, fail }) { if (!deepEqual(value, arg)) fail(`must be the constant value '${String(arg)}'`); }, enum({ arg, value, fail }) { if (Array.isArray(arg) && !arg.some((candidate) => deepEqual(value, candidate))) { const values = arg.map((item) => JSON.stringify(item)).join(', '); fail(`does not have a value in the enumeration [${values}]`); } }, minLength({ arg, value, fail }) { if (typeof value === 'string' && codePoints(value) < arg) { fail(`must be at least ${arg} characters long`); } }, maxLength({ arg, value, fail }) { if (typeof value === 'string' && codePoints(value) > arg) { fail(`must be at most ${arg} characters long`); } }, minimum({ arg, value, fail }) { if (typeof value === 'number' && value < arg) fail(`must have a minimum value of ${arg}`); }, maximum({ arg, value, fail }) { if (typeof value === 'number' && value > arg) fail(`must have a maximum value of ${arg}`); }, // JSON Schema patterns are unanchored: '[0-9]+' accepts 'x1y'. pattern({ arg, value, fail }) { if (typeof value === 'string' && !new RegExp(arg, 'u').test(value)) { fail(`does not match the regex pattern ${arg}`); } }, oneOf({ arg, root, value, location, errors, fail }) { if (!Array.isArray(arg)) return; const results = arg.map((branch: Schema) => validateSchema(branch, value, location, root)); const valid = results.flatMap((result, index) => (result.length === 0 ? [index] : [])); if (valid.length === 1) return; if (valid.length > 1) { fail( `must be valid to one and only one schema, but ${valid.length} are valid with indexes '${valid.join(', ')}'`, ); return; } fail('must be valid to one and only one schema, but 0 are valid'); errors.push(...results.flat()); }, properties({ arg, root, value, location, errors }) { if (!isObject(value) || !isObject(arg)) return; for (const [name, propertySchema] of Object.entries(arg)) { if (!Object.hasOwn(value, name)) continue; errors.push( ...validateSchema(propertySchema as Schema, value[name], `${location}.${name}`, root), ); } }, required({ arg, value, fail }) { if (!isObject(value) || !Array.isArray(arg)) return; for (const name of arg) { if (!Object.hasOwn(value, name)) fail(`required property '${name}' not found`, name); } }, additionalProperties({ arg, schema, root, value, location, errors, fail }) { if (!isObject(value) || (arg !== false && !isObject(arg))) return; const known = isObject(schema.properties) ? schema.properties : {}; for (const name of Object.keys(value)) { if (Object.hasOwn(known, name)) continue; if (arg === false) fail(`property '${name}' ${NOT_DEFINED}`, name); else errors.push(...validateSchema(arg, value[name], `${location}.${name}`, root)); } }, items({ arg, root, value, location, errors }) { if (!Array.isArray(value) || !isObject(arg)) return; value.forEach((item, index) => { errors.push(...validateSchema(arg, item, `${location}[${index}]`, root)); }); }, uniqueItems({ arg, value, fail }) { if (arg !== true || !Array.isArray(value)) return; const seen = new Set(); for (const item of value) { const key = canonical(item); if (seen.has(key)) { fail('must have only unique items in the array'); return; } seen.add(key); } }, // Only the schema's own definitions: '#/$defs/'. $ref({ arg, root, value, location, errors }) { const name = typeof arg === 'string' && arg.startsWith(DEFS) ? arg.slice(DEFS.length) : undefined; const target = name !== undefined && isObject(root.$defs) ? root.$defs[name] : undefined; if (isObject(target)) errors.push(...validateSchema(target, value, location, root)); }, // Anything else is an annotation (description, title, default, readOnly, // genesisType, $schema, $defs, ...) and validates nothing; networknt // doesn't enforce readOnly unless asked to. }; export function validateSchema( schema: Schema, value: unknown, location = '$', root: Schema = schema, ): SchemaError[] { const errors: SchemaError[] = []; if (!isObject(schema)) return errors; const keywords = Object.keys(schema); const ordered = [ ...FIRST_KEYWORDS.filter((keyword) => keywords.includes(keyword)), ...keywords.filter((keyword) => !FIRST_KEYWORDS.includes(keyword)), ]; for (const keyword of ordered) { if (!Object.hasOwn(KEYWORDS, keyword)) continue; KEYWORDS[keyword]({ arg: schema[keyword], schema, root, value, location, errors, fail: (message, property) => errors.push({ keyword, instanceLocation: location, ...(property !== undefined ? { property } : {}), message, }), }); } return errors; } // GenesisMessageDecoder's FieldError for each failure: TEXT is the error's // toString() (": "), FIELD the missing property for // `required` and otherwise the last segment of the location (so an unknown // DETAILS property names 'DETAILS'), PATH the location. export function schemaFieldErrors(errors: SchemaError[]): NackErrorInput[] { return errors.map((error) => ({ '@type': 'FieldError', CODE: 'VALIDATION_ERROR', TEXT: `${error.instanceLocation}: ${error.message}`, FIELD: error.keyword === 'required' ? error.property : error.instanceLocation.slice(error.instanceLocation.lastIndexOf('.') + 1), PATH: error.instanceLocation, })); }