// A small JSON Schema (2020-12) checker for the keywords the AIAX 0.1 schemas use. No dependency.
// Each error: { path (JSON pointer), message (names the field, plain words) }.
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : Number.isInteger(v) ? 'integer' : typeof v);
const fits = (v, t) => t === typeOf(v) || (t === 'number' && typeof v === 'number') ;
const ptr = (p, k) => `${p}/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;
const fieldName = (p) => p.split('/').filter(Boolean).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~')).join('.') || 'the document';
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
const formats = {
uri: (s) => { try { const u = new URL(s); return /^[a-z][a-z0-9+.-]*:$/i.test(u.protocol); } catch { return false; } },
'uri-reference': (s) => typeof s === 'string' && !/\s/.test(s) && (() => { try { new URL(s, 'https://base.example/'); return true; } catch { return false; } })(),
'date-time': (s) => ISO.test(s) && !Number.isNaN(Date.parse(s)),
};
export function check(schema, value, path = '') {
const errs = [];
const add = (p, m) => errs.push({ path: p, message: m });
const name = fieldName(path);
if (schema.oneOf) {
const ok = schema.oneOf.filter((s) => check(s, value, path).length === 0).length;
if (ok !== 1) {
const types = schema.oneOf.map((s) => s.type).flat().join(' or ');
add(path, ok === 0 ? `${name} must be ${types} (and meet its limits)` : `${name} matches more than one allowed form`);
}
return errs;
}
if (schema.type) {
const types = [schema.type].flat();
if (!types.some((t) => fits(value, t))) { add(path, `${name} has the wrong type: expected ${types.join(' or ')}, got ${typeOf(value)}`); return errs; }
}
if (schema.enum && !schema.enum.some((e) => e === value)) add(path, `${name} must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}, got ${JSON.stringify(value)}`);
if (typeof value === 'string') {
if (schema.maxLength !== undefined && [...value].length > schema.maxLength) add(path, `${name} is too long: at most ${schema.maxLength} characters, got ${[...value].length}`);
if (schema.minLength !== undefined && [...value].length < schema.minLength) add(path, `${name} is too short: at least ${schema.minLength} characters`);
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) add(path, `${name} does not match the pattern ${schema.pattern}: ${JSON.stringify(value)}`);
if (schema.format && formats[schema.format] && !formats[schema.format](value)) add(path, `${name} must be a valid ${schema.format}, got ${JSON.stringify(value)}`);
}
if (typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) add(path, `${name} must be at least ${schema.minimum}, got ${value}`);
if (schema.maximum !== undefined && value > schema.maximum) add(path, `${name} must be at most ${schema.maximum}, got ${value}`);
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) add(path, `${name} needs at least ${schema.minItems} item(s), got ${value.length}`);
if (schema.maxItems !== undefined && value.length > schema.maxItems) add(path, `${name} allows at most ${schema.maxItems} item(s), got ${value.length}`);
if (schema.items) value.forEach((v, i) => errs.push(...check(schema.items, v, ptr(path, i))));
}
if (typeOf(value) === 'object') {
for (const r of schema.required ?? []) if (!(r in value)) add(ptr(path, r), `${fieldName(ptr(path, r))} is required but missing`);
const props = schema.properties ?? {};
for (const [k, v] of Object.entries(value)) {
if (props[k]) errs.push(...check(props[k], v, ptr(path, k)));
else if (schema.additionalProperties === false) add(ptr(path, k), `${fieldName(ptr(path, k))} is not an allowed field`);
}
}
return errs;
}
agentgate-source: code mirror on Cloudflare Pages. All repos