import type { ParamsSchema } from "./rpc.ts"; import { definitionId, type Infer, type Schema } from "./schema.ts"; import { isObject } from "./util/data.ts"; import { unwrap } from "./util/fields.ts"; import { base64Length, cidCodec, matchesFormat } from "./util/syntax.ts"; export type Issue = { readonly path: readonly (string | number)[]; readonly message: string }; export type ValidationResult = | { readonly success: true; readonly value: T } | { readonly success: false; readonly issues: readonly Issue[] }; // Where a check is looking, and where it reports what it finds. class Location { constructor(readonly issues: Issue[], readonly path: readonly (string | number)[] = []) {} child(key: string | number): Location { return new Location(this.issues, [...this.path, key]); } fail(message: string): void { this.issues.push({ path: this.path, message }); } length(length: number, min: number | undefined, max: number | undefined): void { if (min !== undefined && length < min) this.fail(`Length must be at least ${min}`); if (max !== undefined && length > max) this.fail(`Length must be at most ${max}`); } } type Check = (value: unknown, at: Location) => void; type Compiled = Schema | ParamsSchema; type Scalar = string | number | boolean; const encoder = new TextEncoder(); const segmenter = new Intl.Segmenter("und", { granularity: "grapheme" }); const graphemes = (value: string) => [...segmenter.segment(value)].length; const MIME = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; function isCompound(value: unknown, key: "$link" | "$bytes"): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; if (isObject(value)) return Object.hasOwn(value, key) && Object.keys(value).length === 1; // Binary wrappers expose the same JSON fields without making Lexicon depend on a codec. return key in value && "toJSON" in value && typeof value.toJSON === "function"; } function constrain( schema: { readonly const?: T; readonly enum?: readonly T[] }, value: T, at: Location, ): void { if (schema.const !== undefined && value !== schema.const) { at.fail(`Expected ${JSON.stringify(schema.const)}`); } if (schema.enum && !schema.enum.includes(value)) at.fail("Value is not in the enum"); } function checkLink(value: unknown, at: Location): void { const link = isCompound(value, "$link") && typeof value.$link === "string" ? value.$link : ""; if (cidCodec(link) === undefined) at.fail("Expected a CID link ({ $link: valid CID })"); } function checkBytes(value: unknown, at: Location): number | undefined { const length = isCompound(value, "$bytes") && typeof value.$bytes === "string" ? base64Length(value.$bytes) : undefined; if (length === undefined) at.fail("Expected bytes ({ $bytes: base64 })"); return length; } function checkBlob(value: unknown, at: Location): value is Record { if (!isObject(value) || value.$type !== "blob") { at.fail("Expected a blob"); return false; } const { ref, mimeType, size } = value; checkLink(ref, at.child("ref")); if (isCompound(ref, "$link") && typeof ref.$link === "string" && cidCodec(ref.$link) !== 0x55) { at.child("ref").fail("Blob CID must use the raw codec"); } if (typeof mimeType !== "string" || !MIME.test(mimeType)) { at.child("mimeType").fail("Expected a MIME type"); } if (typeof size !== "number" || !Number.isSafeInteger(size) || size <= 0) { at.child("size").fail("Expected a positive safe integer"); } return true; } function checker(schema: Compiled, build: (schema: Compiled) => Check): Check { switch (schema.type) { case "string": return (value, at) => { if (typeof value !== "string" || !value.isWellFormed()) { return at.fail("Expected a well-formed string"); } constrain(schema, value, at); if (schema.format && !matchesFormat(schema.format, value)) { at.fail(`Invalid ${schema.format}`); } if (schema.minLength !== undefined || schema.maxLength !== undefined) { at.length(encoder.encode(value).length, schema.minLength, schema.maxLength); } if (schema.minGraphemes !== undefined || schema.maxGraphemes !== undefined) { at.length(graphemes(value), schema.minGraphemes, schema.maxGraphemes); } }; case "integer": return (value, at) => { if (typeof value !== "number" || !Number.isSafeInteger(value)) { return at.fail("Expected a safe integer"); } constrain(schema, value, at); if (schema.minimum !== undefined && value < schema.minimum) { at.fail(`Must be at least ${schema.minimum}`); } if (schema.maximum !== undefined && value > schema.maximum) { at.fail(`Must be at most ${schema.maximum}`); } }; case "boolean": return (value, at) => { if (typeof value !== "boolean") return at.fail("Expected a boolean"); constrain(schema, value, at); }; case "bytes": return (value, at) => { const length = checkBytes(value, at); if (length !== undefined) at.length(length, schema.minLength, schema.maxLength); }; case "cid-link": return checkLink; case "blob": return (value, at) => { if (!checkBlob(value, at)) return; const { mimeType, size } = value; if (schema.maxSize !== undefined && typeof size === "number" && size > schema.maxSize) { at.child("size").fail(`Must be at most ${schema.maxSize}`); } const accepted = (mime: string) => mime === "*/*" || mime === mimeType || (mime.endsWith("/*") && String(mimeType).startsWith(mime.slice(0, -1))); if (schema.accept && typeof mimeType === "string" && !schema.accept.some(accepted)) { at.child("mimeType").fail("MIME type is not accepted"); } }; case "array": { const item = build(schema.items); return (value, at) => { if (!Array.isArray(value)) return at.fail("Expected an array"); at.length(value.length, schema.minLength, schema.maxLength); // Indexing rather than iterating so holes are checked as undefined. for (let i = 0; i < value.length; i++) item(value[i], at.child(i)); }; } case "params": case "object": { const fields = Object.entries(schema.properties).map(([key, field]) => { const { schema, optional, nullable } = unwrap(field); return { key, optional, nullable, check: build(schema) }; }); return (value, at) => { if (!isObject(value)) return at.fail("Expected an object"); if ( Object.hasOwn(value, "$link") || Object.hasOwn(value, "$bytes") || value.$type === "blob" ) { return at.fail("Expected an object, not a compound value"); } for (const { key, optional, nullable, check } of fields) { const item = Object.hasOwn(value, key) ? value[key] : undefined; if (item === undefined) { if (!optional) at.child(key).fail("Required field"); } else if (item !== null || !nullable) check(item, at.child(key)); } }; } case "ref": return build(schema.target); case "record": { const body = build(schema.record); return (value, at) => { if (!isObject(value) || value.$type !== schema.id) { at.child("$type").fail(`Expected ${schema.id}`); } body(value, at); }; } case "union": { const variants = new Map( Object.entries(schema.variants).map(([id, variant]) => [id, build(variant)]), ); return (value, at) => { if (!isObject(value) || typeof value.$type !== "string") { return at.child("$type").fail("Expected a union discriminator"); } const variant = variants.get(value.$type); if (variant) return variant(value, at); if (schema.closed) return at.child("$type").fail("Unknown union variant"); try { definitionId(value.$type); } catch { at.child("$type").fail("Invalid union discriminator"); } }; } } } export function compile( schema: S, ): (value: unknown) => ValidationResult> { const cache = new Map(); // Recursive schemas get a forwarder before their own check exists. function build(schema: Compiled): Check { const cached = cache.get(schema); if (cached) return cached; let check: Check; cache.set(schema, (value, at) => check(value, at)); return check = checker(schema, build); } const check = build(schema); return (value) => { const issues: Issue[] = []; check(value, new Location(issues)); return issues.length === 0 ? { success: true, value: value as Infer } : { success: false, issues }; }; }