diff --git a/lib/compiled-schema-serialization.js b/lib/compiled-schema-serialization.js new file mode 100644 index 0000000..08a5ba9 --- /dev/null +++ b/lib/compiled-schema-serialization.js @@ -0,0 +1,72 @@ +import { getKeyword } from "./keywords.js"; + + +const REGEXP_MARKER = "a6d8f3e1-9b2c-4f7a-8d5b-1c2e3f4a5b6c"; + +export const serialize = (compiledSchema) => { + const ast = compiledSchema.ast ? { ...compiledSchema.ast } : undefined; + if (ast && ast.plugins instanceof Set) { + ast.plugins = [...ast.plugins].map((plugin) => { + if (!plugin?.id) { + throw Error("Cannot serialize plugin without id"); + } + return { id: plugin.id }; + }); + } + + const toSerialize = ast ? { ...compiledSchema, ast } : compiledSchema; + + return JSON.stringify(toSerialize, (_key, value) => { + if (value instanceof RegExp) { + return { [REGEXP_MARKER]: true, source: value.source, flags: value.flags }; + } + + return value; + }); +}; + +export const deserialize = (serialized, options = {}) => { + const parsed = JSON.parse(serialized, (_key, value) => { + if (!value || typeof value !== "object") { + return value; + } + + if (value[REGEXP_MARKER]) { + return new RegExp(value.source, value.flags); + } + + return value; + }); + + if (Array.isArray(parsed?.ast?.plugins)) { + parsed.ast.plugins = new Set(parsed.ast.plugins.map((plugin) => resolvePlugin(plugin.id, options))); + } + + return parsed; +}; + +const resolveBuiltInPlugin = (pluginId) => { + if (typeof pluginId !== "string" || !pluginId.endsWith("#plugin")) { + return; + } + + const keywordId = pluginId.slice(0, -"#plugin".length); + const plugin = getKeyword(keywordId)?.plugin; + if (plugin?.id === pluginId) { + return plugin; + } +}; + +const resolvePlugin = (pluginId, options) => { + const builtInPlugin = resolveBuiltInPlugin(pluginId); + if (builtInPlugin) { + return builtInPlugin; + } + + const customPlugin = options?.pluginsById?.[pluginId]; + if (customPlugin) { + return customPlugin; + } + + throw Error(`Plugin with id '${pluginId}' is not found`); +}; diff --git a/lib/compiled-schema-serialization.spec.ts b/lib/compiled-schema-serialization.spec.ts new file mode 100644 index 0000000..9044ab4 --- /dev/null +++ b/lib/compiled-schema-serialization.spec.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { registerSchema, unregisterSchema } from "../v1/index.js"; +import { compile, deserialize, getSchema, interpret, serialize } from "./experimental.js"; +import * as Instance from "./instance.js"; + + +describe("Compiled Schema Serialization", () => { + const schemaUri = "schema:compiled-serialization"; + const dialectUri = "https://json-schema.org/v1"; + + afterEach(() => { + unregisterSchema(schemaUri); + }); + + test("round-trips RegExp keyword values", async () => { + registerSchema({ pattern: "^a+$" }, schemaUri, dialectUri); + const schema = await getSchema(schemaUri); + const compiled = await compile(schema); + + const restored = deserialize(serialize(compiled)); + + expect(interpret(restored, Instance.fromJs("aaa"))).to.eql({ valid: true }); + expect(interpret(restored, Instance.fromJs("bbb"))).to.eql({ valid: false }); + }); + + test("restores built-in evaluation plugins", async () => { + registerSchema({ unevaluatedProperties: false }, schemaUri, dialectUri); + const schema = await getSchema(schemaUri); + const compiled = await compile(schema); + + const restored = deserialize(serialize(compiled)); + + expect(interpret(restored, Instance.fromJs({ extra: 1 }))).to.eql({ valid: false }); + }); + + test("restores custom plugins from pluginsById", () => { + const plugin = { + id: "https://example.com/plugins/custom", + beforeSchema() {} + }; + + const serialized = JSON.stringify({ + schemaUri: "schema:custom#", + ast: { + "metaData": {}, + "plugins": [{ id: plugin.id }], + "schema:custom#": true + } + }); + + const restored = deserialize(serialized, { + pluginsById: { + [plugin.id]: plugin + } + }); + + expect(restored.ast.plugins instanceof Set).to.equal(true); + }); + + test("throws if plugin id cannot be resolved", () => { + const serialized = JSON.stringify({ + schemaUri: "schema:missing#", + ast: { + "metaData": {}, + "plugins": [{ id: "https://example.com/plugins/missing" }], + "schema:missing#": true + } + }); + + expect(() => { + deserialize(serialized); + }).to.throw("Plugin with id 'https://example.com/plugins/missing' is not found"); + }); +}); diff --git a/lib/experimental.d.ts b/lib/experimental.d.ts index 1718cf9..2cf555a 100644 --- a/lib/experimental.d.ts +++ b/lib/experimental.d.ts @@ -17,9 +17,16 @@ export type CompiledSchema = { ast: AST; }; +export type DeserializeOptions = { + pluginsById?: Record; +}; + +export const serialize: (compiledSchema: CompiledSchema) => string; +export const deserialize: (serialized: string, options?: DeserializeOptions) => CompiledSchema; + type AST = { metaData: Record; - plugins: EvaluationPlugin[]; + plugins: Set; } & Record[] | boolean>; type Node = [keywordId: string, schemaUri: string, keywordValue: A]; diff --git a/lib/experimental.js b/lib/experimental.js index f4d3d02..9236a18 100644 --- a/lib/experimental.js +++ b/lib/experimental.js @@ -10,3 +10,4 @@ export { default as Validation } from "./keywords/validation.js"; export * from "./evaluation-plugins/basic-output.js"; export * from "./evaluation-plugins/detailed-output.js"; export * from "./evaluation-plugins/annotations.js"; +export * from "./compiled-schema-serialization.js";