191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
import type { SysCallMapping } from "../system.ts";
|
|
import { type OutputUnit, Validator, format } from "@cfworker/json-schema";
|
|
import { stripFunctions } from "../util.ts";
|
|
|
|
// Register custom formats
|
|
format.email = (data: string) => data.includes("@");
|
|
format["page-ref"] = (data: string) =>
|
|
data.startsWith("[[") && data.endsWith("]]");
|
|
|
|
const schemaCache = new Map<string, Validator>();
|
|
|
|
function formatErrors(errors: OutputUnit[]): string {
|
|
// Filter out "properties" wrapper errors, keep only the specific leaf errors
|
|
const leafErrors = errors.filter((e) => e.keyword !== "properties");
|
|
const errorsToUse = leafErrors.length > 0 ? leafErrors : errors;
|
|
|
|
return errorsToUse
|
|
.map((e) => {
|
|
// Convert instanceLocation from "#/foo/bar" to "foo.bar"
|
|
const path =
|
|
e.instanceLocation === "#"
|
|
? ""
|
|
: e.instanceLocation.slice(2).replaceAll("/", ".");
|
|
return path ? `${path}: ${e.error}` : e.error;
|
|
})
|
|
.join(", ");
|
|
}
|
|
|
|
export function validateObject(schema: any, object: any): undefined | string {
|
|
try {
|
|
const schemaKey = JSON.stringify(schema);
|
|
if (!schemaCache.has(schemaKey)) {
|
|
const validator = new Validator(schema, "7");
|
|
schemaCache.set(schemaKey, validator);
|
|
}
|
|
const validator = schemaCache.get(schemaKey)!;
|
|
const result = validator.validate(stripFunctions(object));
|
|
if (result.valid) {
|
|
return;
|
|
} else {
|
|
return formatErrors(result.errors);
|
|
}
|
|
} catch (e: any) {
|
|
return e.message;
|
|
}
|
|
}
|
|
|
|
export function validateSchema(schema: any): undefined | string {
|
|
if (schema === null || schema === undefined) {
|
|
return "schema must not be null or undefined";
|
|
}
|
|
if (typeof schema === "boolean") {
|
|
return;
|
|
}
|
|
if (typeof schema !== "object" || Array.isArray(schema)) {
|
|
return "schema must be an object or boolean";
|
|
}
|
|
if (schema.type !== undefined) {
|
|
const validTypes = [
|
|
"string",
|
|
"number",
|
|
"integer",
|
|
"boolean",
|
|
"object",
|
|
"array",
|
|
"null",
|
|
];
|
|
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
for (const t of types) {
|
|
if (!validTypes.includes(t)) {
|
|
return `schema.type must be one of ${validTypes.join(", ")}`;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Best-effort: infer a JSON Schema (draft 2020-12) from the *shape* of a single
|
|
* sample value. Types are guessed from one example, so the result is a hint,
|
|
* not a contract — the top-level schema is marked `"x-inferred": true`.
|
|
*
|
|
* Useful when a tag/object type has no declared schema but an example object
|
|
* exists and you want a plausible schema for it.
|
|
*/
|
|
export function inferFromObject(value: any): any {
|
|
const schema = inferSchemaNode(value);
|
|
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema";
|
|
schema["x-inferred"] = true;
|
|
return schema;
|
|
}
|
|
|
|
/** Infer a bare JSON Schema node for a value, recursing into arrays/objects. */
|
|
function inferSchemaNode(value: any): any {
|
|
if (value === null || value === undefined) {
|
|
return { type: "null" };
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const node: any = { type: "array" };
|
|
if (value.length > 0) {
|
|
node.items = inferSchemaNode(value[0]);
|
|
}
|
|
return node;
|
|
}
|
|
switch (typeof value) {
|
|
case "boolean":
|
|
return { type: "boolean" };
|
|
case "number":
|
|
return { type: Number.isInteger(value) ? "integer" : "number" };
|
|
case "string":
|
|
return { type: "string" };
|
|
case "object": {
|
|
const properties: Record<string, any> = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
properties[k] = inferSchemaNode(v);
|
|
}
|
|
return { type: "object", properties };
|
|
}
|
|
default:
|
|
// functions, symbols, bigint, … — not representable in JSON Schema.
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function jsonschemaSyscalls(): SysCallMapping {
|
|
return {
|
|
"jsonschema.validateObject": {
|
|
callback: (_ctx, schema: any, object: any): undefined | string => {
|
|
return validateObject(schema, object);
|
|
},
|
|
description: "Validates a value against a JSON Schema.",
|
|
parameters: [
|
|
{
|
|
name: "schema",
|
|
type: "table",
|
|
description: "JSON Schema to apply.",
|
|
},
|
|
{ name: "object", description: "Value to validate." },
|
|
],
|
|
returns: [
|
|
{
|
|
type: "string",
|
|
description: "Validation error, or nil when valid.",
|
|
},
|
|
],
|
|
examples: [
|
|
{
|
|
code: 'local schema = {type = "object", properties = {name = {type = "string"}}, required = {"name"}}\nlocal err = jsonschema.validateObject(schema, {name = "John"})',
|
|
},
|
|
],
|
|
},
|
|
"jsonschema.validateSchema": {
|
|
callback: (_ctx, schema: any): undefined | string => {
|
|
return validateSchema(schema);
|
|
},
|
|
description:
|
|
"Checks whether a JSON Schema has a supported top-level shape.",
|
|
parameters: [{ name: "schema", description: "JSON Schema to validate." }],
|
|
returns: [
|
|
{ type: "string", description: "Schema error, or nil when valid." },
|
|
],
|
|
examples: [
|
|
{
|
|
code: 'local err = jsonschema.validateSchema({type = "object"})',
|
|
},
|
|
],
|
|
},
|
|
"jsonschema.inferFromObject": {
|
|
callback: (_ctx, object: any): any => {
|
|
return inferFromObject(object);
|
|
},
|
|
description:
|
|
"Infers a best-effort draft 2020-12 JSON Schema from a sample value.",
|
|
parameters: [
|
|
{
|
|
name: "object",
|
|
description: "Sample value whose shape is inferred.",
|
|
},
|
|
],
|
|
returns: [
|
|
{ type: "table", description: "Inferred schema marked x-inferred." },
|
|
],
|
|
examples: [
|
|
{
|
|
code: 'local schema = jsonschema.inferFromObject({name = "Widget", count = 3})',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|