import type { Diagnostic, JsonValue, Result } from "./types.ts"; export interface ValueSchema { readonly kind: string; parse(value: unknown, path?: string): Result; readonly defaultValue?: T; } const fail = (code: string, message: string, path: string): Result => ({ ok: false, errors: [{ code, message, severity: "error", path }], }); export function string( options: { min?: number; max?: number; default?: string } = {}, ): ValueSchema { return { kind: "string", defaultValue: options.default, parse(value, path = "value") { if (value === undefined && options.default !== undefined) return { ok: true, value: options.default }; if (typeof value !== "string") return fail("schema.string", "Expected a string.", path); if (options.min !== undefined && value.length < options.min) return fail("schema.string.min", `Must contain at least ${options.min} characters.`, path); if (options.max !== undefined && value.length > options.max) return fail("schema.string.max", `Must contain at most ${options.max} characters.`, path); return { ok: true, value }; }, }; } export function number( options: { min?: number; max?: number; default?: number } = {}, ): ValueSchema { return { kind: "number", defaultValue: options.default, parse(value, path = "value") { if (value === undefined && options.default !== undefined) return { ok: true, value: options.default }; if (typeof value !== "number" || !Number.isFinite(value)) return fail("schema.number", "Expected a finite number.", path); if (options.min !== undefined && value < options.min) return fail("schema.number.min", `Must be at least ${options.min}.`, path); if (options.max !== undefined && value > options.max) return fail("schema.number.max", `Must be at most ${options.max}.`, path); return { ok: true, value }; }, }; } export function boolean(defaultValue?: boolean): ValueSchema { return { kind: "boolean", defaultValue, parse(value, path = "value") { if (value === undefined && defaultValue !== undefined) return { ok: true, value: defaultValue }; return typeof value === "boolean" ? { ok: true, value } : fail("schema.boolean", "Expected a boolean.", path); }, }; } export function choice( values: T, options: { default?: T[number] } = {}, ): ValueSchema { const allowed = new Set(values); return { kind: "choice", defaultValue: options.default, parse(value, path = "value") { if (value === undefined && options.default !== undefined) return { ok: true, value: options.default }; return typeof value === "string" && allowed.has(value) ? { ok: true, value: value as T[number] } : fail("schema.choice", `Expected one of: ${values.join(", ")}.`, path); }, }; } export function safeUrl( options: { schemes?: string[]; optional?: boolean } = {}, ): ValueSchema { const schemes = new Set((options.schemes ?? ["https"]).map((value) => value.replace(/:$/, ""))); return { kind: "url", parse(value, path = "value") { if ((value === undefined || value === "") && options.optional) return { ok: true, value: "" }; if (typeof value !== "string" || value.length > 4_096) return fail("schema.url", "Expected a bounded URL.", path); const token = /^\{\{\s*[A-Za-z][A-Za-z0-9_.-]*(?:\|[^{}]*)?\s*\}\}$/; if (token.test(value)) return { ok: true, value }; try { const templated = value.replace( /\{\{\s*[A-Za-z][A-Za-z0-9_.-]*(?:\|[^{}]*)?\s*\}\}/g, "variable", ); const parsed = new URL(templated); return schemes.has(parsed.protocol.replace(/:$/, "")) ? { ok: true, value: parsed.href } : fail("schema.url.scheme", `URL scheme ${parsed.protocol} is not allowed.`, path); } catch { return fail("schema.url", "Expected an absolute URL.", path); } }, }; } export function object>( shape: T, ): ValueSchema<{ [K in keyof T]: ReturnType extends Result ? V : never }> { return { kind: "object", parse(value, path = "value") { if (!value || typeof value !== "object" || Array.isArray(value)) return fail("schema.object", "Expected an object.", path); const source = value as Record; const output: Record = {}; const errors: Diagnostic[] = []; for (const key of Object.keys(source)) { if (!(key in shape)) errors.push({ code: "schema.unknown", message: `Unknown field ${key}.`, severity: "error", path: `${path}.${key}`, }); } for (const [key, schema] of Object.entries(shape)) { const result = schema.parse(source[key], `${path}.${key}`); if (result.ok) output[key] = result.value; else errors.push(...result.errors); } return errors.length ? { ok: false, errors } : { ok: true, value: output as never }; }, }; } export const json: ValueSchema = { kind: "json", parse(value, path = "value") { try { const encoded = JSON.stringify(value); if (encoded === undefined) return fail("schema.json", "Expected JSON-serializable data.", path); return { ok: true, value: JSON.parse(encoded) as JsonValue }; } catch { return fail("schema.json", "Expected JSON-serializable data.", path); } }, }; export function record( valueSchema: ValueSchema, options: { maxKeys?: number } = {}, ): ValueSchema> { return { kind: "record", parse(value, path = "value") { if (!value || typeof value !== "object" || Array.isArray(value)) return fail("schema.record", "Expected an object.", path); const entries = Object.entries(value as Record); if (entries.length > (options.maxKeys ?? 100)) return fail("schema.record.size", "Object contains too many fields.", path); const output: Record = {}; const errors: Diagnostic[] = []; for (const [key, raw] of entries) { const result = valueSchema.parse(raw, `${path}.${key}`); if (result.ok) output[key] = result.value; else errors.push(...result.errors); } return errors.length ? { ok: false, errors } : { ok: true, value: output }; }, }; }