diff --git a/.changeset/precompiled-schemas.md b/.changeset/precompiled-schemas.md new file mode 100644 index 0000000..80d69da --- /dev/null +++ b/.changeset/precompiled-schemas.md @@ -0,0 +1,5 @@ +--- +"schema-utils": minor +--- + +perf: add `schema-utils precompile` to compile a schema ahead of time into a check that `validate` takes as its fourth argument, so valid options are validated without loading `ajv` diff --git a/README.md b/README.md index ce30457..2964750 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,37 @@ class Plugin { export default Plugin; ``` +### Precompiled schemas + +Compiling a schema at runtime loads `ajv`, which costs about 150ms for the first schema of a +process and a few to tens of milliseconds for every other one. A loader or a plugin can compile its +schema ahead of time instead, into a check that `validate` takes as its fourth argument: + +```console +npx schema-utils precompile src/options.json +``` + +This writes `src/options.check.js` next to the schema. Commit it, and publish it with the package: + +```js +import { validate } from "schema-utils"; +import check from "./options.check.js"; +import schema from "./options.json"; + +validate(schema, options, { name: "Loader Name" }, check); +``` + +When the options are valid, the check is all that runs and `ajv` is never loaded. When they are +not, `validate` runs the full validation to report what is wrong, so the errors are the same as +without a check. + +Run `schema-utils precompile --check src/options.json` in CI to fail when the check is missing or +out of date with its schema. The generated module needs `schema-utils` as a dependency of the +package and is CommonJS. + +A relative `$ref` to another file resolves against the schema. To compile a schema from code, use +`precompile(schema, { loadSchema })`, it returns the source of the module. + ### Allow to disable and enable validation (the `validate` function do nothing) This can be useful when you don't want to do validation for `production` builds. diff --git a/bin/schema-utils.js b/bin/schema-utils.js new file mode 100755 index 0000000..3c0603c --- /dev/null +++ b/bin/schema-utils.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ + +require("../dist/cli") + .default(process.argv.slice(2), { log: console.log, error: console.error }) + .then( + (exitCode) => { + process.exitCode = exitCode; + }, + (error) => { + // Before Node.js 15 an unhandled rejection exits with 0 + console.error(error); + process.exitCode = 1; + }, + ); diff --git a/declarations/cli.d.ts b/declarations/cli.d.ts new file mode 100644 index 0000000..134b899 --- /dev/null +++ b/declarations/cli.d.ts @@ -0,0 +1,23 @@ +export default cli; +export type CliIO = { + /** + * prints a message + */ + log: (message: string) => void; + /** + * prints an error + */ + error: (message: string) => void; +}; +/** + * @typedef {object} CliIO + * @property {(message: string) => void} log prints a message + * @property {(message: string) => void} error prints an error + */ +/** + * Runs the command line interface. + * @param {string[]} args arguments without `node` and the script + * @param {CliIO} io where the output goes + * @returns {Promise} exit code + */ +declare function cli(args: string[], io: CliIO): Promise; diff --git a/declarations/index.d.ts b/declarations/index.d.ts index 8202ab6..038be27 100644 --- a/declarations/index.d.ts +++ b/declarations/index.d.ts @@ -5,10 +5,20 @@ export type JSONSchema7 = import("./validate").JSONSchema7; export type ExtendedSchema = import("./validate").ExtendedSchema; export type ValidationErrorConfiguration = import("./validate").ValidationErrorConfiguration; +export type PrecompiledCheck = import("./validate").PrecompiledCheck; +export type PrecompileOptions = import("./precompile").PrecompileOptions; import { ValidationError } from "./validate"; import { disableValidation } from "./validate"; import { enableValidation } from "./validate"; import { needValidate } from "./validate"; +/** + * @param {Parameters} args arguments + * @returns {ReturnType} source of the module + */ +export function precompile( + schema: import("./validate").Schema, + options?: import("./precompile").PrecompileOptions | undefined, +): ReturnType; import { validate } from "./validate"; export { ValidationError, diff --git a/declarations/precompile.d.ts b/declarations/precompile.d.ts new file mode 100644 index 0000000..7b6f8d2 --- /dev/null +++ b/declarations/precompile.d.ts @@ -0,0 +1,26 @@ +export default precompile; +export type PrecompileOptions = { + /** + * loads a schema an external `$ref` points to + */ + loadSchema?: ((uri: string) => Promise) | undefined; +}; +export type Ajv = import("ajv").default; +export type AnySchemaObject = import("ajv").AnySchemaObject; +export type KeywordCxt = import("ajv").KeywordCxt; +export type Schema = import("./validate").Schema; +export type EXPECTED_ANY = any; +/** + * @typedef {object} PrecompileOptions + * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to + */ +/** + * Compiles a schema into the source of a CommonJS module that exports a check for it. + * @param {Schema} schema schema + * @param {PrecompileOptions=} options options + * @returns {Promise} source of the module + */ +declare function precompile( + schema: Schema, + options?: PrecompileOptions | undefined, +): Promise; diff --git a/declarations/runtime.d.ts b/declarations/runtime.d.ts new file mode 100644 index 0000000..f11aa93 --- /dev/null +++ b/declarations/runtime.d.ts @@ -0,0 +1,31 @@ +export = runtime; +/** + * @typedef {object} Runtime + * @property {(a: any, b: any) => boolean} equal deep equality for `const`, `enum` and `uniqueItems` + * @property {(str: string) => number} ucs2length string length in code points for `minLength` and `maxLength` + * @property {any} uri URI parser for `$ref` to an `$id` + * @property {Record} formats formats of `ajv-formats` for `format` + */ +/** @type {Runtime} */ +declare const runtime: Runtime; +declare namespace runtime { + export { Runtime }; +} +type Runtime = { + /** + * deep equality for `const`, `enum` and `uniqueItems` + */ + equal: (a: any, b: any) => boolean; + /** + * string length in code points for `minLength` and `maxLength` + */ + ucs2length: (str: string) => number; + /** + * URI parser for `$ref` to an `$id` + */ + uri: any; + /** + * formats of `ajv-formats` for `format` + */ + formats: Record; +}; diff --git a/declarations/validate.d.ts b/declarations/validate.d.ts index 4e069fa..0122c49 100644 --- a/declarations/validate.d.ts +++ b/declarations/validate.d.ts @@ -3,6 +3,7 @@ export type JSONSchema4 = import("json-schema").JSONSchema4; export type JSONSchema6 = import("json-schema").JSONSchema6; export type JSONSchema7 = import("json-schema").JSONSchema7; export type ErrorObject = import("ajv").ErrorObject; +export type EXPECTED_ANY = any; export type ExtendedSchema = { /** * format minimum @@ -74,6 +75,10 @@ export type ErrorPathNode = { */ size: number; }; +/** + * A check precompiled from a schema with `precompile`, it only says whether the value is valid. + */ +export type PrecompiledCheck = (value: EXPECTED_ANY) => boolean; /** * Whether validation is skipped, shared by every `schema-utils` in the process. */ @@ -95,14 +100,22 @@ export function enableValidation(): void; * @returns {boolean} true when need validate, otherwise false */ export function needValidate(): boolean; +/** + * A check precompiled from a schema with `precompile`, it only says whether the value is valid. + * @callback PrecompiledCheck + * @param {EXPECTED_ANY} value value + * @returns {boolean} true when valid, otherwise false + */ /** * @param {Schema} schema schema * @param {object[] | object} options options * @param {ValidationErrorConfiguration=} configuration configuration + * @param {PrecompiledCheck=} check precompiled check of the schema, so a valid value never compiles the schema * @returns {void} */ export function validate( schema: Schema, options: object[] | object, configuration?: ValidationErrorConfiguration | undefined, + check?: PrecompiledCheck | undefined, ): void; diff --git a/eslint.config.mjs b/eslint.config.mjs index cfe7b09..fb585be 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,4 +8,14 @@ export default defineConfig([ "n/prefer-node-protocol": "off", }, }, + { + files: ["bin/**/*.js"], + rules: { + // Node.js has always skipped the hashbang of the file it runs + "n/no-unsupported-features/es-syntax": [ + "error", + { ignores: ["hashbang"] }, + ], + }, + }, ]); diff --git a/package-lock.json b/package-lock.json index c858b07..adf69ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "schema-utils", - "version": "4.3.3", + "version": "4.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "schema-utils", - "version": "4.3.3", + "version": "4.5.0", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -14,6 +14,9 @@ "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0" }, + "bin": { + "schema-utils": "bin/schema-utils.js" + }, "devDependencies": { "@babel/cli": "^8.0.4", "@babel/core": "^8.0.1", @@ -7728,7 +7731,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 47e5937..c648c3e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,11 @@ "author": "webpack Contrib (https://github.com/webpack-contrib)", "main": "dist/index.js", "types": "declarations/index.d.ts", + "bin": { + "schema-utils": "bin/schema-utils.js" + }, "files": [ + "bin", "dist", "declarations" ], diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..17ab45f --- /dev/null +++ b/src/cli.js @@ -0,0 +1,133 @@ +const USAGE = `Usage: schema-utils precompile [--check] ... + +Compiles every schema into a check next to it, \`options.json\` into \`options.check.js\`, +for the fourth argument of \`validate\`: + + validate(schema, options, configuration, require("./options.check.js")); + +Options: + --check write nothing, fail when a check is missing or out of date`; + +/** + * @param {string} schemaPath path of the schema + * @returns {string} path of its check + */ +function getCheckPath(schemaPath) { + return `${schemaPath.replace(/\.json$/i, "")}.check.js`; +} + +/** + * @param {string} content content + * @returns {string} content with LF line endings and without trailing whitespace + */ +function normalize(content) { + return content.replace(/\r\n?/g, "\n").trim(); +} + +/** + * @typedef {object} CliIO + * @property {(message: string) => void} log prints a message + * @property {(message: string) => void} error prints an error + */ + +/** + * Runs the command line interface. + * @param {string[]} args arguments without `node` and the script + * @param {CliIO} io where the output goes + * @returns {Promise} exit code + */ +async function cli(args, io) { + const [command, ...rest] = args; + + if (command === "--help" || command === "-h") { + io.log(USAGE); + + return 0; + } + + if (command !== "precompile") { + io.error(command ? `Unknown command "${command}"\n\n${USAGE}` : USAGE); + + return 1; + } + + const check = rest.includes("--check"); + const files = rest.filter((arg) => arg !== "--check"); + const unknown = files.find((arg) => arg.startsWith("-")); + + if (unknown) { + io.error(`Unknown option "${unknown}"\n\n${USAGE}`); + + return 1; + } + + if (files.length === 0) { + io.error(`No schema given\n\n${USAGE}`); + + return 1; + } + + const fs = require("fs"); + const path = require("path"); + const { pathToFileURL } = require("url"); + const precompile = require("./precompile").default; + + let exitCode = 0; + + for (const file of files) { + const schemaPath = path.resolve(file); + const checkPath = getCheckPath(schemaPath); + const relativeCheckPath = path.relative(process.cwd(), checkPath); + + let code; + + try { + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + + // A relative `$ref` to another file resolves against the schema itself + if (typeof schema.$id === "undefined") { + schema.$id = pathToFileURL(schemaPath).href; + } + + code = await precompile(schema); + } catch (error) { + io.error( + `Cannot precompile "${file}": ${/** @type {Error} */ (error).message}`, + ); + exitCode = 1; + continue; + } + + let current; + + try { + current = fs.readFileSync(checkPath, "utf8"); + } catch (_err) { + // Not generated yet + } + + if ( + typeof current !== "undefined" && + normalize(current) === normalize(code) + ) { + continue; + } + + if (check) { + io.error( + `"${relativeCheckPath}" is ${ + typeof current === "undefined" ? "missing" : "out of date" + }, run "schema-utils precompile ${file}"`, + ); + exitCode = 1; + continue; + } + + fs.writeFileSync(checkPath, `${code}\n`); + io.log(`"${relativeCheckPath}" updated`); + } + + return exitCode; +} + +export default cli; diff --git a/src/index.js b/src/index.js index 08dc9dc..ff937a8 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,8 @@ /** @typedef {import("./validate").JSONSchema7} JSONSchema7 */ /** @typedef {import("./validate").ExtendedSchema} ExtendedSchema */ /** @typedef {import("./validate").ValidationErrorConfiguration} ValidationErrorConfiguration */ +/** @typedef {import("./validate").PrecompiledCheck} PrecompiledCheck */ +/** @typedef {import("./precompile").PrecompileOptions} PrecompileOptions */ const { ValidationError, @@ -13,10 +15,18 @@ const { validate, } = require("./validate"); +/** + * @param {Parameters} args arguments + * @returns {ReturnType} source of the module + */ +// Loaded on demand, a precompiled schema is generated at build time, never at runtime +const precompile = (...args) => require("./precompile").default(...args); + module.exports = { ValidationError, disableValidation, enableValidation, needValidate, + precompile, validate, }; diff --git a/src/precompile.js b/src/precompile.js new file mode 100644 index 0000000..2eda84a --- /dev/null +++ b/src/precompile.js @@ -0,0 +1,314 @@ +/** @typedef {import("ajv").default} Ajv */ +/** @typedef {import("ajv").AnySchemaObject} AnySchemaObject */ +/** @typedef {import("ajv").KeywordCxt} KeywordCxt */ +/** @typedef {import("./validate").Schema} Schema */ + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +// Compiles a schema into a standalone check, a module exporting `(value) => boolean` that +// `validate` takes as its fourth argument, so a valid value never loads `ajv`. +// +// WHY: compiling a schema with `ajv` at runtime costs about 150ms for the first schema of a process +// (loading `ajv` itself) and a few up to tens of milliseconds for every other one, which every +// webpack plugin and loader pays on every start. The check only answers whether the value is +// valid, the full validation still runs to build the error message when it is not. + +const RUNTIME = "schema-utils/dist/runtime"; + +/** + * @param {Ajv} ajv ajv + * @returns {void} + */ +function addKeywords(ajv) { + const { Name, _ } = require("ajv"); + + // The same constructors `ajv-keywords` accepts + const CONSTRUCTORS = [ + "Object", + "Array", + "Function", + "Number", + "String", + "Date", + "RegExp", + "Buffer", + "Promise", + ]; + + ajv.addKeyword({ + keyword: "instanceof", + schemaType: ["string", "array"], + /** + * @param {KeywordCxt} cxt context + */ + code(cxt) { + const { data, schema } = cxt; + const names = Array.isArray(schema) ? schema : [schema]; + + for (const name of names) { + if (!CONSTRUCTORS.includes(name)) { + throw new Error(`invalid "instanceof" keyword value ${name}`); + } + } + + cxt.fail( + names + .map((name) => _`!(${data} instanceof ${new Name(name)})`) + .reduce((a, b) => _`${a} && ${b}`), + ); + }, + }); + + ajv.addKeyword({ + keyword: "absolutePath", + type: "string", + schemaType: "boolean", + /** + * @param {KeywordCxt} cxt context + */ + code(cxt) { + const { data, schema } = cxt; + + // Keep in sync with `keywords/absolutePath.js` + cxt.fail( + _`${data}.includes("!") || /^(?:file:(?=\\/))?(?:[A-Za-z]:(\\\\|\\/)|\\\\\\\\|\\/)/i.test(${data}) !== ${schema}`, + ); + }, + }); + + ajv.addKeyword({ + keyword: "undefinedAsNull", + before: "enum", + modifying: true, + /** + * @param {KeywordCxt} cxt context + */ + code(cxt) { + const { gen, data, schema, parentSchema, it } = cxt; + + // Keep in sync with `keywords/undefinedAsNull.js` - the value is set to `null` in its parent + // the way the full validation sets it, so a passing check leaves the same value behind + if ( + !schema || + typeof parentSchema.enum === "undefined" || + it.parentData === undefined || + it.parentDataProperty === undefined + ) { + return; + } + + // The value itself has no parent to set when the check is called without one + const condition = + it.dataLevel === 0 + ? _`${data} === undefined && ${it.parentData} !== undefined` + : _`${data} === undefined`; + + gen.if(condition, () => { + gen.assign( + _`${/** @type {import("ajv").Name} */ (it.parentData)}[${it.parentDataProperty}]`, + null, + ); + gen.assign(data, null); + }); + }, + }); +} + +/** + * @param {string} code generated code + * @returns {string} code loading the runtime helpers from `schema-utils` + */ +function useRuntime(code) { + return code.replace( + /require\("ajv\/dist\/runtime\/(\w+)"\)\.default/g, + (match, name) => { + if (!["equal", "ucs2length", "uri"].includes(name)) { + throw new Error(`Unsupported "ajv" runtime helper "${name}"`); + } + + return `require(${JSON.stringify(RUNTIME)}).${name}`; + }, + ); +} + +// Keywords whose value is a schema, an array of schemas or an object of schemas +const SCHEMA_KEYWORDS = [ + "additionalItems", + "additionalProperties", + "contains", + "else", + "if", + "items", + "not", + "propertyNames", + "then", +]; +const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "items", "oneOf"]; +const SCHEMA_MAP_KEYWORDS = [ + "$defs", + "definitions", + "dependencies", + "patternProperties", + "properties", +]; +// Keywords that are not validation keywords but have to stay +const STRUCTURE_KEYWORDS = ["$id", "$schema", "$defs", "definitions"]; + +/** + * Removes every keyword that does not validate, i.e. `title`, `description` and `link`. + * + * WHY: the generated code embeds the parts of the schema its error objects point to, so the + * annotations would only make the module bigger and slower to load. + * @param {EXPECTED_ANY} schema schema + * @param {Set} keywords validation keywords + * @returns {EXPECTED_ANY} schema without the annotations + */ +function stripAnnotations(schema, keywords) { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + return schema; + } + + /** @type {Record} */ + const result = {}; + + for (const key of Object.keys(schema)) { + if (!keywords.has(key)) continue; + + const value = schema[key]; + + if (SCHEMA_ARRAY_KEYWORDS.includes(key) && Array.isArray(value)) { + result[key] = value.map((item) => stripAnnotations(item, keywords)); + } else if (SCHEMA_KEYWORDS.includes(key)) { + result[key] = stripAnnotations(value, keywords); + } else if ( + SCHEMA_MAP_KEYWORDS.includes(key) && + value && + typeof value === "object" + ) { + /** @type {Record} */ + const map = {}; + + for (const name of Object.keys(value)) { + // A `dependencies` entry is either a schema or an array of property names + map[name] = Array.isArray(value[name]) + ? value[name] + : stripAnnotations(value[name], keywords); + } + + result[key] = map; + } else { + result[key] = value; + } + } + + return result; +} + +/** + * Loads the schema an external `$ref` points to, when it points to a file. + * @param {string} uri absolute URI of the schema + * @returns {Promise} schema + */ +async function loadFileSchema(uri) { + if (!uri.startsWith("file:")) { + throw new Error( + `Cannot load the schema "${uri}", pass a \`loadSchema\` option to load it`, + ); + } + + const { readFileSync } = require("fs"); + const { fileURLToPath } = require("url"); + + return JSON.parse(readFileSync(fileURLToPath(uri), "utf8")); +} + +/** + * @typedef {object} PrecompileOptions + * @property {((uri: string) => Promise)=} loadSchema loads a schema an external `$ref` points to + */ + +/** + * Compiles a schema into the source of a CommonJS module that exports a check for it. + * @param {Schema} schema schema + * @param {PrecompileOptions=} options options + * @returns {Promise} source of the module + */ +async function precompile(schema, options = {}) { + const Ajv = require("ajv").default; + const standaloneCode = require("ajv/dist/standalone").default; + const ajvKeywords = require("ajv-keywords").default; + const addFormats = require("ajv-formats").default; + const { _ } = require("ajv"); + + // The same options as the full validation in `validate.js`, without the ones that only shape + // the errors - a check only says whether the value is valid + const ajv = new Ajv({ + strict: false, + $data: true, + messages: false, + code: { + source: true, + optimize: true, + formats: _`require(${RUNTIME}).formats`, + }, + }); + + ajvKeywords(ajv, ["patternRequired"]); + addFormats(ajv, { keywords: false }); + + const addLimitKeyword = require("./keywords/limit").default; + + addLimitKeyword(ajv); + addKeywords(ajv); + + const keywords = new Set([ + ...Object.keys(ajv.RULES.all), + ...STRUCTURE_KEYWORDS, + ]); + const loadSchema = options.loadSchema || loadFileSchema; + /** @type {string[]} */ + const loaded = []; + + ajv.opts.loadSchema = async (uri) => { + loaded.push(uri); + + return stripAnnotations(await loadSchema(uri), keywords); + }; + + const validate = await ajv.compileAsync(stripAnnotations(schema, keywords)); + + let code = useRuntime(standaloneCode(ajv, validate)); + + // The `$id` of a schema read from a file is its absolute URL, which ends up in the code, so the + // URLs of the schemas are made relative to the schema for the generated file to be the same on + // every machine - only those, a value in the schema can be a `file:` URL too + const { $id } = /** @type {AnySchemaObject} */ (schema); + + if (typeof $id === "string" && $id.startsWith("file:")) { + const { fileURLToPath } = require("url"); + const path = require("path"); + + const dir = path.dirname(fileURLToPath($id)); + const urls = [$id, ...loaded] + .filter((url) => url.startsWith("file:")) + // A longer URL first, it can start with a shorter one + .sort((a, b) => b.length - a.length); + + for (const url of urls) { + code = code + .split(url) + .join(path.relative(dir, fileURLToPath(url)).replace(/\\/g, "/")); + } + } + + return `/* eslint-disable */ +// @ts-nocheck +/* + * This file was automatically generated by "schema-utils". + * DO NOT MODIFY BY HAND. + */ +${code}`; +} + +export default precompile; diff --git a/src/runtime.js b/src/runtime.js new file mode 100644 index 0000000..ea9880b --- /dev/null +++ b/src/runtime.js @@ -0,0 +1,40 @@ +// The helpers a precompiled check (see `precompile.js`) loads at runtime. +// +// WHY: the generated module requires `schema-utils/dist/runtime` instead of `ajv/dist/runtime/*` +// because the packages shipping a check depend on `schema-utils`, not on `ajv`, which is not +// guaranteed to be resolvable from their files. Every helper is a getter, so a check only loads +// the ones its schema needs. + +/* eslint-disable jsdoc/reject-any-type */ + +/** + * @typedef {object} Runtime + * @property {(a: any, b: any) => boolean} equal deep equality for `const`, `enum` and `uniqueItems` + * @property {(str: string) => number} ucs2length string length in code points for `minLength` and `maxLength` + * @property {any} uri URI parser for `$ref` to an `$id` + * @property {Record} formats formats of `ajv-formats` for `format` + */ + +/** @type {Runtime} */ +const runtime = /** @type {any} */ ({}); + +Object.defineProperties(runtime, { + equal: { + enumerable: true, + get: () => require("ajv/dist/runtime/equal").default, + }, + ucs2length: { + enumerable: true, + get: () => require("ajv/dist/runtime/ucs2length").default, + }, + uri: { + enumerable: true, + get: () => require("ajv/dist/runtime/uri").default, + }, + formats: { + enumerable: true, + get: () => require("ajv-formats/dist/formats").fullFormats, + }, +}); + +module.exports = runtime; diff --git a/src/validate.js b/src/validate.js index 8b6e563..a18ccf7 100644 --- a/src/validate.js +++ b/src/validate.js @@ -47,6 +47,9 @@ const getAjv = memoize(() => { /** @typedef {import("json-schema").JSONSchema7} JSONSchema7 */ /** @typedef {import("ajv").ErrorObject} ErrorObject */ +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + /** * @typedef {object} ExtendedSchema * @property {(string | number)=} formatMinimum format minimum @@ -447,17 +450,35 @@ function validateObject(schema, options) { return compiledSchema.errors ? filterErrors(compiledSchema.errors) : []; } +/** + * A check precompiled from a schema with `precompile`, it only says whether the value is valid. + * @callback PrecompiledCheck + * @param {EXPECTED_ANY} value value + * @returns {boolean} true when valid, otherwise false + */ + /** * @param {Schema} schema schema * @param {object[] | object} options options * @param {ValidationErrorConfiguration=} configuration configuration + * @param {PrecompiledCheck=} check precompiled check of the schema, so a valid value never compiles the schema * @returns {void} */ -function validate(schema, options, configuration) { +function validate(schema, options, configuration, check) { if (!needValidate()) { return; } + // Only an invalid value needs `ajv`, to tell what is wrong with it + if ( + check && + (Array.isArray(options) + ? options.every((item) => check(item)) + : check(options)) + ) { + return; + } + let errors = []; if (Array.isArray(options)) { diff --git a/test/helpers/precompiled.js b/test/helpers/precompiled.js new file mode 100644 index 0000000..46c58e4 --- /dev/null +++ b/test/helpers/precompiled.js @@ -0,0 +1,78 @@ +import precompile from "../../src/precompile"; + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +/** @type {WeakMap boolean>>} */ +const cache = new WeakMap(); + +/** + * Loads the generated module the way a package shipping it would, with its runtime helpers from + * the sources instead of the published `schema-utils`. + * @param {string} code source of the module + * @returns {(value: EXPECTED_ANY) => boolean} check + */ +function load(code) { + const module = { exports: {} }; + /** + * @param {string} request request + * @returns {EXPECTED_ANY} module + */ + const fakeRequire = (request) => { + if (request === "schema-utils/dist/runtime") { + return require("../../src/runtime"); + } + + throw new Error(`The precompiled check requires "${request}"`); + }; + + // eslint-disable-next-line no-new-func + new Function("module", "exports", "require", code)( + module, + module.exports, + fakeRequire, + ); + + return /** @type {(value: EXPECTED_ANY) => boolean} */ (module.exports); +} + +/** + * @param {EXPECTED_ANY} schema schema + * @returns {Promise<(value: EXPECTED_ANY) => boolean>} precompiled check of the schema + */ +export function getCheck(schema) { + let check = cache.get(schema); + + if (!check) { + check = precompile(schema).then(load); + cache.set(schema, check); + } + + return check; +} + +/** + * Copies plain objects and arrays, keeping every other value as is, so a check and the full + * validation each get their own value to modify. + * @template T + * @param {T} value value + * @returns {T} copy + */ +export function copy(value) { + if (Array.isArray(value)) { + return /** @type {T} */ (value.map(copy)); + } + + if (value && Object.getPrototypeOf(value) === Object.prototype) { + /** @type {Record} */ + const result = {}; + + for (const key of Object.keys(value)) { + result[key] = copy(/** @type {EXPECTED_ANY} */ (value)[key]); + } + + return /** @type {T} */ (result); + } + + return value; +} diff --git a/test/index.test.js b/test/index.test.js index 60d83ae..6798734 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,6 +3,7 @@ import webpackSchema from "webpack/schemas/WebpackOptions.json"; import { validate } from "../src"; import schema from "./fixtures/schema.json"; +import { copy, getCheck } from "./helpers/precompiled"; // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ @@ -37,6 +38,19 @@ describe("validation", () => { expect(error).toBeUndefined(); }); + + it(`should pass the precompiled check for ${name}`, async () => { + const check = await getCheck(testSchema); + const checked = copy(config); + const validated = copy(config); + + expect(check(checked)).toBe(true); + + validate(testSchema, validated, options.name); + + // `undefinedAsNull` sets the value the same way in both + expect(checked).toEqual(validated); + }); } /** @@ -67,6 +81,12 @@ describe("validation", () => { throw new Error("Validation didn't fail"); }); + + it(`should fail the precompiled check for ${name}`, async () => { + const check = await getCheck(schema); + + expect(check(copy(config))).toBe(false); + }); } createSuccessTestCase("empty array", { @@ -3300,6 +3320,10 @@ describe("validation", () => { (msg) => expect(msg).toMatchSnapshot(), ); + createSuccessTestCase("array with enum and undefinedAsNull and undefined", { + arrayStringAndEnum: ["foo", undefined], + }); + createFailedTestCase( "array with enum and undefinedAsNull #2", { diff --git a/test/precompile.test.js b/test/precompile.test.js new file mode 100644 index 0000000..5a4c09a --- /dev/null +++ b/test/precompile.test.js @@ -0,0 +1,381 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { pathToFileURL } from "url"; + +import { precompile, validate } from "../src"; +import cli from "../src/cli"; + +import { getCheck } from "./helpers/precompiled"; + +const runtime = require("../src/runtime"); + +/** + * `fs.rmSync` needs Node.js 14.14.0 + * @param {string} target file or directory to remove + */ +function remove(target) { + if (fs.lstatSync(target).isDirectory()) { + for (const name of fs.readdirSync(target)) { + remove(path.join(target, name)); + } + + fs.rmdirSync(target); + } else { + fs.unlinkSync(target); + } +} + +describe("precompile", () => { + /** @type {string} */ + let dir; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "schema-utils-")); + }); + + afterEach(() => { + remove(dir); + }); + + /** + * @param {string} name file name + * @param {object} content JSON content + * @returns {string} path of the file + */ + function writeJson(name, content) { + const file = path.join(dir, name); + + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(content)); + + return file; + } + + /** + * @param {string[]} args arguments + * @returns {Promise<{ exitCode: number, log: string[], error: string[] }>} result + */ + async function runCli(args) { + /** @type {string[]} */ + const log = []; + /** @type {string[]} */ + const error = []; + const exitCode = await cli(args, { + log: (message) => log.push(message), + error: (message) => error.push(message), + }); + + return { exitCode, log, error }; + } + + describe("validate with a check", () => { + const schema = { + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false, + }; + + it("should not run the full validation when the check passes", () => { + const check = jest.fn(() => true); + + // The value is invalid, a passing check is trusted + expect(() => + validate(schema, { name: 1 }, { name: "Plugin" }, check), + ).not.toThrow(); + expect(check).toHaveBeenCalledTimes(1); + }); + + it("should run the full validation when the check fails", () => { + expect(() => + validate(schema, { name: 1 }, { name: "Plugin" }, () => false), + ).toThrow(/^Invalid configuration object\. Plugin has been initialized/); + }); + + it("should not throw when only the check fails", () => { + expect(() => + validate(schema, { name: "a" }, { name: "Plugin" }, () => false), + ).not.toThrow(); + }); + + it("should check every item of an array", () => { + const check = jest.fn( + (value) => + typeof (/** @type {{ name: unknown }} */ (value).name) === "string", + ); + + expect(() => + validate(schema, [{ name: "a" }, { name: 1 }], {}, check), + ).toThrow(/configuration\[1\]\.name should be a string/); + expect(check).toHaveBeenCalledTimes(2); + }); + + it("should validate with a precompiled check", async () => { + const check = await getCheck(schema); + + expect(() => validate(schema, { name: "a" }, {}, check)).not.toThrow(); + expect(() => validate(schema, { name: 1 }, {}, check)).toThrow( + /configuration\.name should be a string/, + ); + }); + }); + + it("should generate a module with the banner", async () => { + const code = await precompile({ type: "string" }); + + expect(code).toMatch( + /^\/\* eslint-disable \*\/\n\/\/ @ts-nocheck\n\/\*\n \* This file was automatically generated by "schema-utils"\./, + ); + }); + + it("should not keep annotations in the generated code", async () => { + const code = await precompile({ + title: "Plugin options", + description: "The options", + type: "object", + properties: { + // A property named like an annotation is kept + description: { + description: "The description of the thing", + link: "https://example.com/description", + enum: ["a", "b"], + }, + }, + definitions: { + Unused: { description: "Never referenced", type: "string" }, + }, + }); + + expect(code).not.toMatch(/Plugin options|The options|example\.com/); + expect(code).toMatch(/"description"/); + }); + + it("should load the runtime helpers from schema-utils", async () => { + const code = await precompile({ + type: "object", + properties: { + name: { type: "string", minLength: 2 }, + date: { type: "string", format: "date" }, + mode: { enum: [{ a: 1 }] }, + }, + }); + + expect(code).not.toMatch(/require\("ajv/); + expect(code).toMatch( + /require\("schema-utils\/dist\/runtime"\)\.ucs2length/, + ); + expect(code).toMatch(/require\("schema-utils\/dist\/runtime"\)\.formats/); + expect(code).toMatch(/require\("schema-utils\/dist\/runtime"\)\.equal/); + }); + + it("should expose every runtime helper", () => { + expect(runtime.equal({ a: [1] }, { a: [1] })).toBe(true); + expect(runtime.ucs2length("😀")).toBe(1); + expect(typeof runtime.uri.parse).toBe("function"); + expect(typeof runtime.formats.date).toBe("object"); + }); + + it("should check instanceof", async () => { + const check = await getCheck({ + type: "object", + properties: { + fn: { instanceof: "Function" }, + test: { instanceof: ["RegExp", "Function"] }, + }, + }); + + expect(check({ fn: () => {}, test: /a/ })).toBe(true); + expect(check({ fn: () => {}, test: () => {} })).toBe(true); + expect(check({ fn: "a" })).toBe(false); + expect(check({ test: "a" })).toBe(false); + }); + + it("should reject an unknown instanceof constructor", async () => { + await expect(precompile({ instanceof: "Map" })).rejects.toThrow( + 'invalid "instanceof" keyword value Map', + ); + }); + + it("should check absolutePath", async () => { + const check = await getCheck({ + type: "object", + properties: { + absolute: { type: "string", absolutePath: true }, + relative: { type: "string", absolutePath: false }, + }, + }); + + expect(check({ absolute: "/a", relative: "a" })).toBe(true); + expect(check({ absolute: "C:\\a" })).toBe(true); + expect(check({ absolute: "file:///a" })).toBe(true); + expect(check({ absolute: "a" })).toBe(false); + expect(check({ absolute: "/a!b" })).toBe(false); + expect(check({ relative: "/a" })).toBe(false); + }); + + it("should resolve an external $ref to a file", async () => { + writeJson("definitions.json", { + definitions: { + Name: { description: "A name", type: "string" }, + }, + }); + + const file = writeJson("options.json", { + type: "object", + properties: { name: { $ref: "./definitions.json#/definitions/Name" } }, + }); + const schema = JSON.parse(fs.readFileSync(file, "utf8")); + + schema.$id = pathToFileURL(file).href; + + const code = await precompile(schema); + + // Every path is relative to the schema, so the code is the same on every machine + expect(code).not.toMatch(/file:/); + expect(code).not.toContain(dir); + expect(code).not.toMatch(/A name/); + }); + + it("should keep a file URL that is a value", async () => { + const file = writeJson("options.json", {}); + const url = pathToFileURL(path.join(dir, "assets/logo.png")).href; + const check = await getCheck({ + $id: pathToFileURL(file).href, + enum: [url], + }); + + expect(check(url)).toBe(true); + expect(check("assets/logo.png")).toBe(false); + }); + + it("should not set undefinedAsNull on a value without a parent", async () => { + const check = await getCheck({ undefinedAsNull: true, enum: [null] }); + + expect(check(undefined)).toBe(false); + expect(check(null)).toBe(true); + expect( + check(undefined, { parentData: [undefined], parentDataProperty: 0 }), + ).toBe(true); + }); + + it("should load an external $ref with loadSchema", async () => { + const loadSchema = jest.fn(async () => ({ type: "string" })); + + await precompile( + { + $id: "https://example.com/options.json", + $ref: "https://example.com/name.json", + }, + { loadSchema }, + ); + + expect(loadSchema).toHaveBeenCalledWith("https://example.com/name.json"); + }); + + it("should not load an external $ref that is not a file", async () => { + await expect( + precompile({ + $id: "https://example.com/options.json", + $ref: "https://example.com/name.json", + }), + ).rejects.toThrow( + 'Cannot load the schema "https://example.com/name.json", pass a `loadSchema` option to load it', + ); + }); + + describe("cli", () => { + it("should print the usage", async () => { + const result = await runCli(["--help"]); + + expect(result.exitCode).toBe(0); + expect(result.log[0]).toMatch(/^Usage: schema-utils precompile/); + }); + + it("should fail without a command", async () => { + const result = await runCli([]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Usage: schema-utils precompile/); + }); + + it("should fail with an unknown command", async () => { + const result = await runCli(["compile"]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Unknown command "compile"/); + }); + + it("should fail with an unknown option", async () => { + const result = await runCli(["precompile", "--write", "options.json"]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Unknown option "--write"/); + }); + + it("should fail without a schema", async () => { + const result = await runCli(["precompile", "--check"]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^No schema given/); + }); + + it("should write the check next to the schema and keep it up to date", async () => { + writeJson("definitions.json", { + definitions: { Name: { type: "string" } }, + }); + + const file = writeJson("src/options.json", { + type: "object", + properties: { + name: { $ref: "../definitions.json#/definitions/Name" }, + }, + }); + const checkFile = path.join(dir, "src/options.check.js"); + + let result = await runCli(["precompile", "--check", file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/options\.check\.js" is missing/); + expect(fs.existsSync(checkFile)).toBe(false); + + result = await runCli(["precompile", file]); + + expect(result.exitCode).toBe(0); + expect(result.log[0]).toMatch(/options\.check\.js" updated/); + + const code = fs.readFileSync(checkFile, "utf8"); + + expect(code).not.toContain(dir); + + result = await runCli(["precompile", "--check", file]); + + expect(result).toEqual({ exitCode: 0, log: [], error: [] }); + + // Nothing to write when it is up to date + result = await runCli(["precompile", file]); + + expect(result).toEqual({ exitCode: 0, log: [], error: [] }); + + writeJson("src/options.json", { type: "string" }); + + result = await runCli(["precompile", "--check", file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/options\.check\.js" is out of date/); + expect(fs.readFileSync(checkFile, "utf8")).toBe(code); + }); + + it("should report a schema it cannot precompile and go on", async () => { + const broken = path.join(dir, "broken.json"); + + fs.writeFileSync(broken, "{"); + + const file = writeJson("options.json", { type: "string" }); + const result = await runCli(["precompile", broken, file]); + + expect(result.exitCode).toBe(1); + expect(result.error[0]).toMatch(/^Cannot precompile ".+broken\.json": /); + expect(fs.existsSync(path.join(dir, "options.check.js"))).toBe(true); + }); + }); +});