180 lines
6.5 KiB
TypeScript
180 lines
6.5 KiB
TypeScript
import type { Diagnostic, JsonValue, Result } from "./types.ts";
|
|
|
|
export interface ValueSchema<T = unknown> {
|
|
readonly kind: string;
|
|
parse(value: unknown, path?: string): Result<T>;
|
|
readonly defaultValue?: T;
|
|
}
|
|
|
|
const fail = (code: string, message: string, path: string): Result<never> => ({
|
|
ok: false,
|
|
errors: [{ code, message, severity: "error", path }],
|
|
});
|
|
|
|
export function string(
|
|
options: { min?: number; max?: number; default?: string } = {},
|
|
): ValueSchema<string> {
|
|
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<number> {
|
|
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<boolean> {
|
|
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<const T extends readonly string[]>(
|
|
values: T,
|
|
options: { default?: T[number] } = {},
|
|
): ValueSchema<T[number]> {
|
|
const allowed = new Set<string>(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<string> {
|
|
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<T extends Record<string, ValueSchema>>(
|
|
shape: T,
|
|
): ValueSchema<{ [K in keyof T]: ReturnType<T[K]["parse"]> extends Result<infer V> ? 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<string, unknown>;
|
|
const output: Record<string, unknown> = {};
|
|
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<JsonValue> = {
|
|
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<T>(
|
|
valueSchema: ValueSchema<T>,
|
|
options: { maxKeys?: number } = {},
|
|
): ValueSchema<Record<string, T>> {
|
|
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<string, unknown>);
|
|
if (entries.length > (options.maxKeys ?? 100))
|
|
return fail("schema.record.size", "Object contains too many fields.", path);
|
|
const output: Record<string, T> = {};
|
|
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 };
|
|
},
|
|
};
|
|
}
|