diff --git a/apps/heft/config/heft.json b/apps/heft/config/heft.json index 76eac0e3d31..081495e01aa 100644 --- a/apps/heft/config/heft.json +++ b/apps/heft/config/heft.json @@ -22,6 +22,22 @@ } }, + "precompile-json-schemas": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft", + "pluginName": "run-script-plugin", + "options": { + "scriptPath": "lib-commonjs/plugins/PrecompileJsonSchemasPlugin.js", + "scriptOptions": { + "sourceFolder": "src/schemas", + "destinationFolders": ["lib-commonjs/schemas"], + "esmDestinationFolders": ["lib-esm/schemas"] + } + } + } + }, + "copy-legacy-compatibility-start-js": { "taskDependencies": ["typescript"], "taskPlugin": { diff --git a/apps/heft/heft-plugin.json b/apps/heft/heft-plugin.json index 417896dc263..f2a7b5817a3 100644 --- a/apps/heft/heft-plugin.json +++ b/apps/heft/heft-plugin.json @@ -26,6 +26,11 @@ } ] }, + { + "pluginName": "precompile-json-schemas-plugin", + "entryPoint": "./lib-commonjs/plugins/PrecompileJsonSchemasPlugin", + "optionsSchema": "./lib-commonjs/schemas/precompile-json-schemas-options.schema.json" + }, { "pluginName": "run-script-plugin", "entryPoint": "./lib-commonjs/plugins/RunScriptPlugin", diff --git a/apps/heft/src/configuration/HeftPluginConfiguration.ts b/apps/heft/src/configuration/HeftPluginConfiguration.ts index ac2ab9334c4..db89e8bc5fc 100644 --- a/apps/heft/src/configuration/HeftPluginConfiguration.ts +++ b/apps/heft/src/configuration/HeftPluginConfiguration.ts @@ -11,7 +11,7 @@ import { type IHeftTaskPluginDefinitionJson } from './HeftPluginDefinition'; import type { IHeftConfigurationJsonPluginSpecifier } from '../utilities/CoreConfigFiles'; -import heftPluginSchema from '../schemas/heft-plugin.schema.json'; +import heftPluginSchemaValidator from '../schemas/heft-plugin.validator.js'; export interface IHeftPluginConfigurationJson { lifecyclePlugins?: IHeftLifecyclePluginDefinitionJson[]; @@ -20,7 +20,10 @@ export interface IHeftPluginConfigurationJson { const HEFT_PLUGIN_CONFIGURATION_FILENAME: 'heft-plugin.json' = 'heft-plugin.json'; -const _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(heftPluginSchema); +const _jsonSchema: JsonSchema = JsonSchema.fromCompiledValidator( + heftPluginSchemaValidator, + 'heft-plugin.schema.json' +); const _pluginConfigurationPromises: Map> = new Map(); /** diff --git a/apps/heft/src/configuration/HeftPluginDefinition.ts b/apps/heft/src/configuration/HeftPluginDefinition.ts index 822ef3453e3..2b28562d6a9 100644 --- a/apps/heft/src/configuration/HeftPluginDefinition.ts +++ b/apps/heft/src/configuration/HeftPluginDefinition.ts @@ -3,8 +3,13 @@ import * as path from 'node:path'; -import { InternalError, JsonSchema } from '@rushstack/node-core-library'; +import { InternalError, JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import copyFilesValidator from '../schemas/copy-files-options.validator.js'; +import deleteFilesValidator from '../schemas/delete-files-options.validator.js'; +import precompileSchemasValidator from '../schemas/precompile-json-schemas-options.validator.js'; +import runScriptValidator from '../schemas/run-script-options.validator.js'; +import setEnvironmentVariablesValidator from '../schemas/set-environment-variables-plugin.validator.js'; import type { IHeftPlugin } from '../pluginFramework/IHeftPlugin'; import type { IScopedLogger } from '../pluginFramework/logging/ScopedLogger'; import type { HeftLifecycleSession } from '../pluginFramework/HeftLifecycleSession'; @@ -192,6 +197,14 @@ export interface IHeftPluginDefinitionOptions { packageRoot: string; } +const BUILT_IN_OPTIONS_VALIDATORS: ReadonlyMap = new Map([ + ['copy-files-options.schema.json', copyFilesValidator], + ['delete-files-options.schema.json', deleteFilesValidator], + ['precompile-json-schemas-options.schema.json', precompileSchemasValidator], + ['run-script-options.schema.json', runScriptValidator], + ['set-environment-variables-plugin.schema.json', setEnvironmentVariablesValidator] +]); + export abstract class HeftPluginDefinitionBase { #heftPluginDefinitionJson: IHeftPluginDefinitionJson; #pluginPackageName: string; @@ -222,7 +235,14 @@ export abstract class HeftPluginDefinitionBase { options.packageRoot, options.heftPluginDefinitionJson.optionsSchema ); - this.#optionsSchema = JsonSchema.fromFile(resolvedSchemaPath); + const builtInValidator: IJsonSchemaCompiledValidator | undefined = + options.packageName === '@rushstack/heft' && + path.dirname(resolvedSchemaPath) === path.resolve(options.packageRoot, 'lib-commonjs/schemas') + ? BUILT_IN_OPTIONS_VALIDATORS.get(path.basename(resolvedSchemaPath)) + : undefined; + this.#optionsSchema = builtInValidator + ? JsonSchema.fromCompiledValidator(builtInValidator, path.basename(resolvedSchemaPath)) + : JsonSchema.fromFile(resolvedSchemaPath); } } diff --git a/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts new file mode 100644 index 00000000000..000fd0bb0b0 --- /dev/null +++ b/apps/heft/src/configuration/test/PrecompiledSchemas.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { JsonSchema, type IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; + +import anythingValidator from '../../schemas/anything.validator.js'; +import copyFilesValidator from '../../schemas/copy-files-options.validator.js'; +import deleteFilesValidator from '../../schemas/delete-files-options.validator.js'; +import legacyValidator from '../../schemas/heft-legacy.validator.js'; +import heftPluginValidator from '../../schemas/heft-plugin.validator.js'; +import heftValidator from '../../schemas/heft.validator.js'; +import nodeServiceValidator from '../../schemas/node-service.validator.js'; +import precompileValidator from '../../schemas/precompile-json-schemas-options.validator.js'; +import runScriptValidator from '../../schemas/run-script-options.validator.js'; +import setEnvironmentValidator from '../../schemas/set-environment-variables-plugin.validator.js'; +import { HeftPluginConfiguration } from '../HeftPluginConfiguration'; + +describe('Heft built-in schemas', () => { + const packageRoot: string = path.resolve(__dirname, '../../..'); + const schemaFolder: string = path.join(packageRoot, 'lib-commonjs/schemas'); + const validators: ReadonlyMap = new Map([ + ['anything.schema.json', anythingValidator], + ['copy-files-options.schema.json', copyFilesValidator], + ['delete-files-options.schema.json', deleteFilesValidator], + ['heft-legacy.schema.json', legacyValidator], + ['heft-plugin.schema.json', heftPluginValidator], + ['heft.schema.json', heftValidator], + ['node-service.schema.json', nodeServiceValidator], + ['precompile-json-schemas-options.schema.json', precompileValidator], + ['run-script-options.schema.json', runScriptValidator], + ['set-environment-variables-plugin.schema.json', setEnvironmentValidator] + ]); + + it('publishes a precompiled validator for every JSON schema', () => { + const schemaFiles: string[] = fs + .readdirSync(schemaFolder) + .filter((fileName: string) => fileName.endsWith('.schema.json')); + expect(schemaFiles.length).toBeGreaterThan(0); + expect(schemaFiles.length).toBe(validators.size); + + let esmImportCount: number = 0; + for (const schemaFile of schemaFiles) { + const schemaPath: string = path.join(schemaFolder, schemaFile); + const validatorPath: string = schemaPath.replace(/\.schema\.json$/, '.validator.js'); + expect(fs.existsSync(validatorPath)).toBe(true); + const esmValidatorPath: string = path.join(packageRoot, 'lib-esm/schemas', path.basename(validatorPath)); + const esmCode: string = fs.readFileSync(esmValidatorPath, 'utf8'); + if (/import .* from "@rushstack\/node-core-library\/lib\/JsonSchemaRuntime"/.test(esmCode)) { + esmImportCount++; + } + expect(esmCode).toMatch(/export default validate\d+;/); + expect(esmCode).not.toContain('require('); + const validator: IJsonSchemaCompiledValidator = validators.get(schemaFile)!; + expect(typeof validator).toBe('function'); + + const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(validator, schemaFile); + const sourceSchema: JsonSchema = JsonSchema.fromFile(schemaPath); + for (const example of [{}, { invalid: true }, { taskPlugins: [] }]) { + let sourceValid: boolean = true; + try { + sourceSchema.validateObject(example, 'test.json'); + } catch { + sourceValid = false; + } + if (sourceValid) { + expect(() => compiledSchema.validateObject(example, 'test.json')).not.toThrow(); + } else { + expect(() => compiledSchema.validateObject(example, 'test.json')).toThrow(); + } + } + } + expect(esmImportCount).toBeGreaterThan(0); + }); + + it('loads every built-in plugin option schema without compiling a JSON schema', async () => { + const fromFileSpy = jest.spyOn(JsonSchema, 'fromFile'); + try { + const plugins: HeftPluginConfiguration = await HeftPluginConfiguration.loadFromPackageAsync( + packageRoot, + '@rushstack/heft' + ); + const manifest: { taskPlugins: { pluginName: string }[] } = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'heft-plugin.json'), 'utf8') + ); + for (const plugin of manifest.taskPlugins) { + expect(plugins.tryGetTaskPluginDefinitionByName(plugin.pluginName)).toBeDefined(); + } + expect(fromFileSpy).not.toHaveBeenCalled(); + } finally { + fromFileSpy.mockRestore(); + } + }); +}); diff --git a/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts new file mode 100644 index 00000000000..bdf3422104a --- /dev/null +++ b/apps/heft/src/plugins/PrecompileJsonSchemasPlugin.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonSchema } from '@rushstack/node-core-library'; + +import type { HeftConfiguration } from '../configuration/HeftConfiguration'; +import type { IHeftTaskPlugin } from '../pluginFramework/IHeftPlugin'; +import type { IHeftTaskSession, IHeftTaskRunHookOptions } from '../pluginFramework/HeftTaskSession'; +import type { IRunScriptOptions } from './RunScriptPlugin'; + +interface IPrecompileJsonSchemasPluginOptions { + sourceFolder: string; + destinationFolders?: string[]; + esmDestinationFolders?: string[]; +} + +const PLUGIN_NAME: 'precompile-json-schemas-plugin' = 'precompile-json-schemas-plugin'; + +async function precompileSchemasAsync( + buildFolderPath: string, + options: IPrecompileJsonSchemasPluginOptions, + runOptions: IHeftTaskRunHookOptions +): Promise { + const sourceFolder: string = path.resolve(buildFolderPath, options.sourceFolder); + const schemaPaths: string[] = await runOptions.globAsync('**/*.schema.json', { + cwd: sourceFolder, + absolute: true + }); + + for (const schemaPath of schemaPaths) { + try { + const relativePath: string = path + .relative(sourceFolder, schemaPath) + .replace(/\.schema\.json$/, '.validator.js'); + if (options.destinationFolders?.length) { + const validatorCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath); + for (const destinationFolder of options.destinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, validatorCode, { ensureFolderExists: true }); + } + } + if (options.esmDestinationFolders?.length) { + const esmCode: string = JsonSchema.compileStandaloneCodeFromFile(schemaPath, undefined, { + moduleFormat: 'esm' + }); + for (const destinationFolder of options.esmDestinationFolders) { + const destinationPath: string = path.resolve(buildFolderPath, destinationFolder, relativePath); + await FileSystem.writeFileAsync(destinationPath, esmCode, { ensureFolderExists: true }); + } + } + } catch (error) { + const message: string = error instanceof Error ? error.message : String(error); + const referenceHint: string = /can't resolve reference|missing ref/i.test(message) + ? ' External $ref dependencies are not supported by this plugin.' + : ''; + throw new Error( + `Failed to precompile "${schemaPath}": ${message}${referenceHint}`, + { cause: error } + ); + } + } + return schemaPaths.length; +} + +// The Heft package builds itself using the previously published version of Heft. Until that +// version includes this plugin, its run-script-plugin invokes this entry point after TypeScript emits it. +export async function runAsync(options: IRunScriptOptions): Promise { + const { sourceFolder, destinationFolders, esmDestinationFolders } = options.scriptOptions; + if ( + typeof sourceFolder !== 'string' || + (destinationFolders !== undefined && + (!Array.isArray(destinationFolders) || + !destinationFolders.every((folder: unknown) => typeof folder === 'string'))) || + (esmDestinationFolders !== undefined && + (!Array.isArray(esmDestinationFolders) || + !esmDestinationFolders.every((folder: unknown) => typeof folder === 'string'))) || + !(destinationFolders?.length || esmDestinationFolders?.length) + ) { + throw new Error( + 'Schema precompilation requires a string sourceFolder and at least one non-empty ' + + 'destinationFolders or esmDestinationFolders string array.' + ); + } + const count: number = await precompileSchemasAsync( + options.heftConfiguration.buildFolderPath, + { sourceFolder, destinationFolders, esmDestinationFolders }, + options.runOptions + ); + options.heftTaskSession.logger.terminal.writeLine(`Precompiled ${count} JSON schemas.`); +} + +export default class PrecompileJsonSchemasPlugin + implements IHeftTaskPlugin +{ + public apply( + taskSession: IHeftTaskSession, + heftConfiguration: HeftConfiguration, + options: IPrecompileJsonSchemasPluginOptions + ): void { + taskSession.hooks.run.tapPromise(PLUGIN_NAME, async (runOptions: IHeftTaskRunHookOptions) => { + const count: number = await precompileSchemasAsync(heftConfiguration.buildFolderPath, options, runOptions); + taskSession.logger.terminal.writeLine(`Precompiled ${count} JSON schemas.`); + }); + } +} diff --git a/apps/heft/src/schemas/precompile-json-schemas-options.schema.json b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json new file mode 100644 index 00000000000..a2a49f31842 --- /dev/null +++ b/apps/heft/src/schemas/precompile-json-schemas-options.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Precompile JSON Schemas Plugin Options", + "type": "object", + "additionalProperties": false, + "required": ["sourceFolder"], + "anyOf": [ + { "required": ["destinationFolders"] }, + { "required": ["esmDestinationFolders"] } + ], + "properties": { + "sourceFolder": { + "type": "string", + "description": "Folder containing JSON schema files, relative to the project root." + }, + "destinationFolders": { + "type": "array", + "minItems": 1, + "items": { "type": "string" }, + "description": "Folders for generated CommonJS validator modules, relative to the project root." + }, + "esmDestinationFolders": { + "type": "array", + "minItems": 1, + "items": { "type": "string" }, + "description": "Folders for generated ESM validator modules, relative to the project root." + } + } +} diff --git a/apps/heft/src/schemas/validator.d.ts b/apps/heft/src/schemas/validator.d.ts new file mode 100644 index 00000000000..09a766cd001 --- /dev/null +++ b/apps/heft/src/schemas/validator.d.ts @@ -0,0 +1,4 @@ +declare module '*.validator.js' { + const validator: import('@rushstack/node-core-library').IJsonSchemaCompiledValidator; + export default validator; +} diff --git a/apps/heft/src/utilities/CoreConfigFiles.ts b/apps/heft/src/utilities/CoreConfigFiles.ts index ff221359a7d..cd1f4f3d4fa 100644 --- a/apps/heft/src/utilities/CoreConfigFiles.ts +++ b/apps/heft/src/utilities/CoreConfigFiles.ts @@ -15,6 +15,9 @@ import type { IRigConfig } from '@rushstack/rig-package'; import type { IDeleteOperation } from '../plugins/DeleteFilesPlugin'; import type { INodeServicePluginConfiguration } from '../plugins/NodeServicePlugin'; +import heftSchemaValidator from '../schemas/heft.validator.js'; +import legacySchemaValidator from '../schemas/heft-legacy.validator.js'; +import nodeServiceSchemaValidator from '../schemas/node-service.validator.js'; import { Constants } from './Constants'; export interface IHeftConfigurationJsonActionReference { @@ -108,11 +111,11 @@ export class CoreConfigFiles { } }; - const schemaObject: object = await import('../schemas/heft.schema.json'); // eslint-disable-next-line require-atomic-updates _heftConfigFileLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, - jsonSchemaObject: schemaObject, + jsonSchemaValidator: heftSchemaValidator, + jsonSchemaValidatorName: 'heft.schema.json', propertyInheritanceDefaults: { array: { inheritanceType: InheritanceType.append }, object: { inheritanceType: InheritanceType.merge } @@ -156,11 +159,11 @@ export class CoreConfigFiles { // match the legacy schema. We don't need to worry about the resulting object, we just // want to see if it parses. We will use the ConfigurationFile class to load it to ensure // that we follow the "extends" chain for the entire config file. - const legacySchemaObject: object = await import('../schemas/heft-legacy.schema.json'); const legacyConfigFileLoader: ProjectConfigurationFile = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.heftConfigurationProjectRelativeFilePath, - jsonSchemaObject: legacySchemaObject + jsonSchemaValidator: legacySchemaValidator, + jsonSchemaValidatorName: 'heft-legacy.schema.json' }); await legacyConfigFileLoader.loadConfigurationFileForProjectAsync(terminal, projectPath, rigConfig); } catch (e2) { @@ -230,11 +233,11 @@ export class CoreConfigFiles { rigConfig?: IRigConfig | undefined ): Promise { if (!_nodeServiceConfigurationLoader) { - const schemaObject: object = await import('../schemas/node-service.schema.json'); // eslint-disable-next-line require-atomic-updates _nodeServiceConfigurationLoader = new ProjectConfigurationFile({ projectRelativeFilePath: CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath, - jsonSchemaObject: schemaObject + jsonSchemaValidator: nodeServiceSchemaValidator, + jsonSchemaValidatorName: 'node-service.schema.json' }); } diff --git a/libraries/heft-config-file/README.md b/libraries/heft-config-file/README.md index c51a808d73a..ef0dd793e62 100644 --- a/libraries/heft-config-file/README.md +++ b/libraries/heft-config-file/README.md @@ -142,7 +142,7 @@ const loader = new ProjectConfigurationFile({ // Path relative to the project root projectRelativeFilePath: 'config/my-plugin.json', - // Provide either jsonSchemaPath or jsonSchemaObject + // Provide jsonSchemaPath, jsonSchemaObject, or jsonSchemaValidator jsonSchemaPath: require.resolve('./schemas/my-plugin.schema.json'), // Configure how properties merge when a config file uses "extends" @@ -200,7 +200,7 @@ const config = loader.loadConfigurationFile(terminal, '/absolute/path/to/config. ### JSON schema -Supply either a file path or an inline object: +Supply a file path, an inline object, or a precompiled validator: ```typescript // From a file path @@ -208,8 +208,26 @@ Supply either a file path or an inline object: // Inline { jsonSchemaObject: { type: 'object', properties: { ... } } } + +// Generated by Heft's precompile-json-schemas-plugin +import myPluginValidator from './schemas/my-plugin.validator.js'; +{ jsonSchemaValidator: myPluginValidator } ``` +To generate validators, add a build task using `@rushstack/heft`'s +`precompile-json-schemas-plugin` with `sourceFolder` pointing at the folder containing +`*.schema.json` files, `destinationFolders` pointing at the CommonJS output folders, +and `esmDestinationFolders` pointing at the ESM output folders. The plugin emits +`.validator.js` modules beside each published schema (for example, +`my-plugin.schema.json` becomes `my-plugin.validator.js`). +At least one destination list is required. Each schema is compiled independently; +references must resolve within the same schema file rather than to another file. +Publish both the JSON schema (for editors and tooling) and its generated validator, +and reference the latter through `jsonSchemaValidator` at runtime. Generated +validators import `@rushstack/node-core-library/lib/JsonSchemaRuntime` for +AJV runtime helpers, so publishing projects only need their existing dependency +on `@rushstack/node-core-library`. + Schema validation runs **after** all inheritance merging, so the schema describes the shape of the final merged result. diff --git a/libraries/heft-config-file/src/ConfigurationFileBase.ts b/libraries/heft-config-file/src/ConfigurationFileBase.ts index 0382fe2099d..b07e44fad4e 100644 --- a/libraries/heft-config-file/src/ConfigurationFileBase.ts +++ b/libraries/heft-config-file/src/ConfigurationFileBase.ts @@ -5,7 +5,13 @@ import * as nodeJsPath from 'node:path'; import { JSONPath } from 'jsonpath-plus'; -import { JsonSchema, JsonFile, Import, FileSystem } from '@rushstack/node-core-library'; +import { + JsonSchema, + JsonFile, + Import, + FileSystem, + type IJsonSchemaCompiledValidator +} from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; interface IConfigurationJson { @@ -367,6 +373,8 @@ export type IConfigurationFileOptionsWithJsonSchemaFilePath< */ jsonSchemaPath: string; jsonSchemaObject?: never; + jsonSchemaValidator?: never; + jsonSchemaValidatorName?: never; }; /** @@ -382,6 +390,28 @@ export type IConfigurationFileOptionsWithJsonSchemaObject< */ jsonSchemaObject: object; jsonSchemaPath?: never; + jsonSchemaValidator?: never; + jsonSchemaValidatorName?: never; + }; + +/** + * @beta + */ +export type IConfigurationFileOptionsWithJsonSchemaValidator< + TConfigurationFile, + TExtraOptions extends {} +> = IConfigurationFileOptionsBase & + TExtraOptions & { + /** + * A precompiled JSON schema validator generated during the build. + */ + jsonSchemaValidator: IJsonSchemaCompiledValidator; + /** + * The schema filename to use in diagnostics for this validator. + */ + jsonSchemaValidatorName?: string; + jsonSchemaPath?: never; + jsonSchemaObject?: never; }; /** @@ -389,7 +419,8 @@ export type IConfigurationFileOptionsWithJsonSchemaObject< */ export type IConfigurationFileOptions = | IConfigurationFileOptionsWithJsonSchemaFilePath - | IConfigurationFileOptionsWithJsonSchemaObject; + | IConfigurationFileOptionsWithJsonSchemaObject + | IConfigurationFileOptionsWithJsonSchemaValidator; interface IJsonPathCallbackObject { path: string; @@ -450,12 +481,16 @@ export abstract class ConfigurationFileBase JsonSchema.fromCompiledValidator(jsonSchemaValidator, jsonSchemaValidatorName); + } else if (jsonSchemaObject) { this.#getSchema = () => JsonSchema.fromLoadedObject(jsonSchemaObject); } else { this.#getSchema = () => JsonSchema.fromFile(jsonSchemaPath); diff --git a/libraries/heft-config-file/src/index.ts b/libraries/heft-config-file/src/index.ts index bd912a1ea15..6b0bd2cc352 100644 --- a/libraries/heft-config-file/src/index.ts +++ b/libraries/heft-config-file/src/index.ts @@ -14,6 +14,7 @@ export { type IConfigurationFileOptionsBase, type IConfigurationFileOptionsWithJsonSchemaFilePath, type IConfigurationFileOptionsWithJsonSchemaObject, + type IConfigurationFileOptionsWithJsonSchemaValidator, type IConfigurationFileOptions, type ICustomJsonPathMetadata, type ICustomPropertyInheritance, diff --git a/libraries/heft-config-file/src/test/PrecompiledValidator.test.ts b/libraries/heft-config-file/src/test/PrecompiledValidator.test.ts new file mode 100644 index 00000000000..19b21cd6624 --- /dev/null +++ b/libraries/heft-config-file/src/test/PrecompiledValidator.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IJsonSchemaCompiledValidator } from '@rushstack/node-core-library'; +import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; + +import { ProjectConfigurationFile } from '../ProjectConfigurationFile'; + +describe('precompiled schema validation', () => { + const projectFolder: string = __dirname; + const terminal: Terminal = new Terminal(new StringBufferTerminalProvider(false)); + + it('uses the precompiled validator to load project configuration', () => { + const validator = jest.fn((data: unknown): boolean => (data as { thing?: string }).thing === 'A'); + const loader: ProjectConfigurationFile<{ thing: string }> = new ProjectConfigurationFile({ + projectRelativeFilePath: 'simplestConfigFile/simplestConfigFile.json', + jsonSchemaValidator: validator + }); + + expect(loader.loadConfigurationFileForProject(terminal, projectFolder).thing).toBe('A'); + expect(validator).toHaveBeenCalledTimes(1); + expect((validator.mock.calls[0][0] as { thing: string }).thing).toBe('A'); + }); + + it('reports validation errors from the precompiled validator', () => { + const validator: IJsonSchemaCompiledValidator = Object.assign( + (_data: unknown): boolean => false, + { + errors: [ + { + instancePath: '/thing', + message: 'must be a string', + keyword: 'type', + schemaPath: '#/properties/thing/type', + params: { type: 'string' } + } + ] + } + ); + const loader: ProjectConfigurationFile<{ thing: string }> = new ProjectConfigurationFile({ + projectRelativeFilePath: 'simplestConfigFile/simplestConfigFile.json', + jsonSchemaValidator: validator + }); + + expect(() => loader.loadConfigurationFileForProject(terminal, projectFolder)).toThrow(/must be a string/); + }); +}); diff --git a/libraries/node-core-library/src/JsonSchema.ts b/libraries/node-core-library/src/JsonSchema.ts index 8864740ef41..7ff3e5fa946 100644 --- a/libraries/node-core-library/src/JsonSchema.ts +++ b/libraries/node-core-library/src/JsonSchema.ts @@ -5,6 +5,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import Ajv, { type Options as AjvOptions, type ErrorObject, type ValidateFunction } from 'ajv'; +import standaloneCode from 'ajv/dist/standalone'; import AjvDraft04 from 'ajv-draft-04'; import addFormats from 'ajv-formats'; @@ -64,6 +65,15 @@ export interface IJsonSchemaCustomFormat { validate: (data: T) => boolean; } +/** + * A compiled AJV-compatible validator, such as one exported by AJV standalone code. + * @public + */ +export interface IJsonSchemaCompiledValidator { + (data: unknown): boolean; + errors?: ErrorObject[] | null; +} + /** * Callback function arguments for {@link JsonSchema.validateObjectWithCallback} * @public @@ -173,11 +183,35 @@ export type IJsonSchemaFromFileOptions = IJsonSchemaLoadOptions; */ export type IJsonSchemaFromObjectOptions = IJsonSchemaLoadOptions; +/** + * Options for {@link JsonSchema.compileStandaloneCodeFromFile}. + * @public + */ +export interface IJsonSchemaStandaloneCodeOptions { + /** + * The output module format. Defaults to CommonJS. + */ + moduleFormat?: 'commonjs' | 'esm'; +} + const JSON_SCHEMA_URL_PREFIX_BY_JSON_SCHEMA_VERSION: Map = new Map([ ['draft-04', 'http://json-schema.org/draft-04/schema'], ['draft-07', 'http://json-schema.org/draft-07/schema'] ]); +const STANDALONE_RUNTIME_MODULE: string = '@rushstack/node-core-library/lib/JsonSchemaRuntime'; +const STANDALONE_RUNTIME_EXPORTS: ReadonlyMap = new Map([ + ['ajv/dist/runtime/equal', 'equal'], + ['ajv/dist/runtime/parseJson', 'parseJson'], + ['ajv/dist/runtime/quote', 'quote'], + ['ajv/dist/runtime/timestamp', 'timestamp'], + ['ajv/dist/runtime/ucs2length', 'ucs2length'], + ['ajv/dist/runtime/uri', 'uri'], + ['ajv/dist/runtime/validation_error', 'validationError'], + ['ajv-formats/dist/formats', 'formats'], + ['ajv-formats/dist/limit', 'limit'] +]); + /** * Helper function to determine the json-schema version to target for validation. */ @@ -203,7 +237,8 @@ function _inferJsonSchemaVersion({ $schema }: JsonObject): JsonSchemaVersion | u export class JsonSchema { private _dependentSchemas: JsonSchema[] = []; private _filename: string = ''; - private _validator: ValidateFunction | undefined = undefined; + private _shortName: string | undefined = undefined; + private _validator: IJsonSchemaCompiledValidator | undefined = undefined; private _schemaObject: JsonObject | undefined = undefined; private _schemaVersion: JsonSchemaVersion | undefined = undefined; private _customFormats: @@ -259,6 +294,81 @@ export class JsonSchema { return schema; } + /** + * Wraps an already compiled AJV-compatible validator without loading or compiling a schema at runtime. + * The validator must expose AJV's `errors` property after a failed validation. + * @public + */ + public static fromCompiledValidator(validator: IJsonSchemaCompiledValidator, shortName?: string): JsonSchema { + const schema: JsonSchema = new JsonSchema(); + schema._validator = validator; + schema._shortName = shortName; + return schema; + } + + /** + * Compiles a schema file into standalone AJV code. + * @remarks + * CommonJS output (the default) uses a literal `require` call; ESM output uses + * a static import from `@rushstack/node-core-library/lib/JsonSchemaRuntime`. + * The consuming project only needs a dependency on `@rushstack/node-core-library`. + * Custom format validator functions cannot be serialized into standalone code. + * @public + */ + public static compileStandaloneCodeFromFile( + filename: string, + options?: IJsonSchemaFromFileOptions, + codeOptions?: IJsonSchemaStandaloneCodeOptions + ): string { + if (options?.customFormats && Object.keys(options.customFormats).length > 0) { + throw new Error('Standalone schema compilation does not support customFormats validation functions'); + } + const schema: JsonSchema = JsonSchema.fromFile(filename, options); + const { ajv, validator } = schema._compileValidator({ code: { source: true } }); + const code: string = standaloneCode(ajv, validator); + const runtimeImportPattern: RegExp = /\brequire\((['"])((?:ajv|ajv-formats)\/[^'"]+)\1\)/g; + const runtimeExports: Set = new Set(); + const rewrittenCode: string = code.replace( + runtimeImportPattern, + (_match: string, _quote: string, specifier: string) => { + const exportName: string | undefined = STANDALONE_RUNTIME_EXPORTS.get(specifier); + if (!exportName) { + throw new Error(`Unsupported AJV standalone runtime dependency: ${specifier}`); + } + runtimeExports.add(exportName); + return codeOptions?.moduleFormat === 'esm' + ? exportName + : `require(${JSON.stringify(STANDALONE_RUNTIME_MODULE)}).${exportName}`; + } + ); + if (codeOptions?.moduleFormat === 'esm') { + const validatorExport: RegExpMatchArray | null = rewrittenCode.match( + /\bmodule\.exports\s*=\s*(validate\d+);/ + ); + if (!validatorExport) { + throw new Error('Unexpected AJV standalone output: missing validator export'); + } + const exportedValidators: string[] = [ + ...rewrittenCode.matchAll(/\bmodule\.exports(?:\.default)?\s*=\s*(validate\d+);/g) + ].map((match: RegExpMatchArray) => match[1]); + if (exportedValidators.some((exportName: string) => exportName !== validatorExport[1])) { + throw new Error('Unexpected AJV standalone output: inconsistent validator exports'); + } + + const esmCode: string = rewrittenCode + .replace(/^(['"])use strict\1;?/, '') + .replace(/\bmodule\.exports(?:\.default)?\s*=\s*validate\d+;/g, ''); + if (/\brequire\s*\(|\bmodule\.exports\b/.test(esmCode)) { + throw new Error('Unexpected AJV standalone output: unsupported CommonJS dependency or export'); + } + const importStatements: string = runtimeExports.size + ? `import { ${[...runtimeExports].join(', ')} } from ${JSON.stringify(STANDALONE_RUNTIME_MODULE)};` + : ''; + return `${importStatements}\n${esmCode}\nexport default ${validatorExport[1]};\n`; + } + return rewrittenCode; + } + /** * Returns a short name for this schema, for use in error messages. * @remarks @@ -266,6 +376,9 @@ export class JsonSchema { * field is used if available. */ public get shortName(): string { + if (this._shortName !== undefined) { + return this._shortName; + } if (!this._filename) { if (this._schemaObject) { const schemaWithId: ISchemaWithId = this._schemaObject as ISchemaWithId; @@ -287,76 +400,80 @@ export class JsonSchema { * Any dependencies will be compiled as well. */ public ensureCompiled(): void { - this._ensureLoaded(); - if (!this._validator) { - const targetSchemaVersion: JsonSchemaVersion | undefined = - this._schemaVersion ?? _inferJsonSchemaVersion(this._schemaObject); - const validatorOptions: AjvOptions = { - strictSchema: true, - allowUnionTypes: true - }; - - let validator: Ajv; - // Keep legacy support for older draft-04 schema - switch (targetSchemaVersion) { - case 'draft-04': { - validator = new AjvDraft04(validatorOptions); - break; - } + this._validator = this._compileValidator({}).validator; + } + } - case 'draft-07': - default: { - validator = new Ajv(validatorOptions); - break; - } + private _compileValidator(options: AjvOptions): { ajv: Ajv; validator: ValidateFunction } { + this._ensureLoaded(); + const targetSchemaVersion: JsonSchemaVersion | undefined = + this._schemaVersion ?? _inferJsonSchemaVersion(this._schemaObject!); + const validatorOptions: AjvOptions = { + strictSchema: true, + allowUnionTypes: true, + ...options + }; + + let validator: Ajv; + // Keep legacy support for older draft-04 schema + switch (targetSchemaVersion) { + case 'draft-04': { + validator = new AjvDraft04(validatorOptions); + break; } - // Enable json-schema format validation - // https://ajv.js.org/packages/ajv-formats.html - addFormats(validator); - if (this._customFormats) { - for (const [name, format] of Object.entries(this._customFormats)) { - validator.addFormat(name, { ...format, async: false }); - } + case 'draft-07': + default: { + validator = new Ajv(validatorOptions); + break; } + } - const collectedSchemas: JsonSchema[] = []; - const seenObjects: Set = new Set(); - const seenIds: Set = new Set(); + // Enable json-schema format validation + // https://ajv.js.org/packages/ajv-formats.html + addFormats(validator); + if (this._customFormats) { + for (const [name, format] of Object.entries(this._customFormats)) { + validator.addFormat(name, { ...format, async: false }); + } + } - this._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); + const collectedSchemas: JsonSchema[] = []; + const seenObjects: Set = new Set(); + const seenIds: Set = new Set(); - // Unless explicitly rejected, scan the top-level keys of each schema for vendor - // extension keys matching the x-- pattern and register them with - // AJV so that strict mode does not reject them as unknown keywords. - if (!this._rejectVendorExtensionKeywords) { - const vendorKeywords: Set = new Set(); - _collectVendorExtensionKeywords(this._schemaObject, vendorKeywords); - for (const collectedSchema of collectedSchemas) { - _collectVendorExtensionKeywords(collectedSchema._schemaObject, vendorKeywords); - } - for (const keyword of vendorKeywords) { - validator.addKeyword(keyword); - } - } + this._collectDependentSchemas(collectedSchemas, this._dependentSchemas, seenObjects, seenIds); - // Validate each schema in order. We specifically do not supply them all together, because we want - // to make sure that circular references will fail to validate. + // Unless explicitly rejected, scan the top-level keys of each schema for vendor + // extension keys matching the x-- pattern and register them with + // AJV so that strict mode does not reject them as unknown keywords. + if (!this._rejectVendorExtensionKeywords) { + const vendorKeywords: Set = new Set(); + _collectVendorExtensionKeywords(this._schemaObject, vendorKeywords); for (const collectedSchema of collectedSchemas) { - validator.validateSchema(collectedSchema._schemaObject) as boolean; - if (validator.errors && validator.errors.length > 0) { - throw new Error( - `Failed to validate schema "${collectedSchema.shortName}":` + - os.EOL + - _formatErrorDetails(validator.errors) - ); - } - validator.addSchema(collectedSchema._schemaObject); + _collectVendorExtensionKeywords(collectedSchema._schemaObject, vendorKeywords); + } + for (const keyword of vendorKeywords) { + validator.addKeyword(keyword); } + } - this._validator = validator.compile(this._schemaObject); + // Validate each schema in order. We specifically do not supply them all together, because we want + // to make sure that circular references will fail to validate. + for (const collectedSchema of collectedSchemas) { + validator.validateSchema(collectedSchema._schemaObject) as boolean; + if (validator.errors && validator.errors.length > 0) { + throw new Error( + `Failed to validate schema "${collectedSchema.shortName}":` + + os.EOL + + _formatErrorDetails(validator.errors) + ); + } + validator.addSchema(collectedSchema._schemaObject); } + + return { ajv: validator, validator: validator.compile(this._schemaObject) }; } /** @@ -404,7 +521,7 @@ export class JsonSchema { } if (this._validator && !this._validator(jsonObject)) { - const errorDetails: string = _formatErrorDetails(this._validator.errors!); + const errorDetails: string = _formatErrorDetails(this._validator.errors ?? []); const args: IJsonSchemaErrorInfo = { details: errorDetails diff --git a/libraries/node-core-library/src/JsonSchemaRuntime.ts b/libraries/node-core-library/src/JsonSchemaRuntime.ts new file mode 100644 index 00000000000..5a833bd47a9 --- /dev/null +++ b/libraries/node-core-library/src/JsonSchemaRuntime.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as equalModule from 'ajv/dist/runtime/equal'; +import * as parseJsonModule from 'ajv/dist/runtime/parseJson'; +import * as quoteModule from 'ajv/dist/runtime/quote'; +import * as timestampModule from 'ajv/dist/runtime/timestamp'; +import * as ucs2lengthModule from 'ajv/dist/runtime/ucs2length'; +import * as uriModule from 'ajv/dist/runtime/uri'; +import * as validationErrorModule from 'ajv/dist/runtime/validation_error'; +import * as formatsModule from 'ajv-formats/dist/formats'; +import * as limitModule from 'ajv-formats/dist/limit'; + +// Node's ESM import of a CommonJS module wraps its exports under `default`. +// Normalize that wrapper so generated standalone code sees the same shape as require(). +function _asCommonJsExports(imported: T): T { + const defaultExport: unknown = (imported as unknown as { default?: unknown }).default; + return defaultExport && + typeof defaultExport === 'object' && + '__esModule' in defaultExport && + defaultExport.__esModule === true + ? (defaultExport as T) + : imported; +} + +export const equal: typeof equalModule = _asCommonJsExports(equalModule); +export const parseJson: typeof parseJsonModule = _asCommonJsExports(parseJsonModule); +export const quote: typeof quoteModule = _asCommonJsExports(quoteModule); +export const timestamp: typeof timestampModule = _asCommonJsExports(timestampModule); +export const ucs2length: typeof ucs2lengthModule = _asCommonJsExports(ucs2lengthModule); +export const uri: typeof uriModule = _asCommonJsExports(uriModule); +export const validationError: typeof validationErrorModule = _asCommonJsExports(validationErrorModule); +export const formats: typeof formatsModule = _asCommonJsExports(formatsModule); +export const limit: typeof limitModule = _asCommonJsExports(limitModule); diff --git a/libraries/node-core-library/src/index.ts b/libraries/node-core-library/src/index.ts index 3f4592cf6b1..99d0f421f0f 100644 --- a/libraries/node-core-library/src/index.ts +++ b/libraries/node-core-library/src/index.ts @@ -109,11 +109,13 @@ export { } from './JsonFile'; export { + type IJsonSchemaCompiledValidator, type IJsonSchemaErrorInfo, type IJsonSchemaCustomFormat, type IJsonSchemaFromFileOptions, type IJsonSchemaFromObjectOptions, type IJsonSchemaLoadOptions, + type IJsonSchemaStandaloneCodeOptions, type IJsonSchemaValidateOptions, type IJsonSchemaValidateObjectWithOptions, JsonSchema, diff --git a/libraries/node-core-library/src/test/JsonSchema.test.ts b/libraries/node-core-library/src/test/JsonSchema.test.ts index 44a482d9398..3cbf201867d 100644 --- a/libraries/node-core-library/src/test/JsonSchema.test.ts +++ b/libraries/node-core-library/src/test/JsonSchema.test.ts @@ -1,8 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { execFileSync } from 'node:child_process'; +import * as path from 'node:path'; +import { runInNewContext } from 'node:vm'; + +import Ajv, { type ValidateFunction } from 'ajv'; + import { JsonFile, type JsonObject } from '../JsonFile'; -import { JsonSchema, type IJsonSchemaErrorInfo } from '../JsonSchema'; +import { + JsonSchema, + type IJsonSchemaCompiledValidator, + type IJsonSchemaErrorInfo +} from '../JsonSchema'; +import * as JsonSchemaRuntime from '../JsonSchemaRuntime'; const SCHEMA_PATH: string = `${__dirname}/test-data/test-schemas/test-schema.schema.json`; const DRAFT_04_SCHEMA_PATH: string = `${__dirname}/test-data/test-schemas/test-schema-draft-04.schema.json`; @@ -120,6 +131,177 @@ describe(JsonSchema.name, () => { }); }); + test('wraps a compiled validator without loading a schema and preserves error formatting', () => { + const validator: ValidateFunction = new Ajv().compile({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + const compiledValidator: IJsonSchemaCompiledValidator = validator; + const compiledSchema: JsonSchema = JsonSchema.fromCompiledValidator(compiledValidator, 'compiled schema'); + const loadSpy = jest.spyOn(JsonFile, 'load'); + try { + expect(compiledSchema.shortName).toBe('compiled schema'); + expect(() => compiledSchema.ensureCompiled()).not.toThrow(); + expect(() => compiledSchema.validateObject({ name: 'valid' }, 'input.json')).not.toThrow(); + expect(() => compiledSchema.validateObject({}, 'input.json')).toThrow( + /JSON validation failed:\s+input\.json\s+Error: #\s+must have required property 'name'/ + ); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + expect(JsonSchema.fromCompiledValidator(validator).shortName).toBe('(anonymous schema)'); + }); + + describe(JsonSchema.compileStandaloneCodeFromFile.name, () => { + test('deep runtime exports match the AJV helpers used by standalone code', () => { + expect(JsonSchemaRuntime.equal.default).toBe(require('ajv/dist/runtime/equal').default); + expect(JsonSchemaRuntime.ucs2length.default).toBe(require('ajv/dist/runtime/ucs2length').default); + expect(JsonSchemaRuntime.uri.default).toBe(require('ajv/dist/runtime/uri').default); + expect(JsonSchemaRuntime.formats.fullFormats).toBe(require('ajv-formats/dist/formats').fullFormats); + }); + + test('defaults to CommonJS output', () => { + const defaultCode: string = JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH); + expect( + JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH, undefined, { + moduleFormat: 'commonjs' + }) + ).toBe(defaultCode); + expect(defaultCode).toContain('module.exports'); + expect(defaultCode).toMatch( + /require\(["']@rushstack\/node-core-library\/lib\/JsonSchemaRuntime["']\)/ + ); + expect(defaultCode).not.toMatch(/require\(["']ajv(?:-formats)?\//); + expect(defaultCode).not.toContain('createRequire'); + }); + + function loadStandaloneSchema( + filename: string, + options?: Parameters[1] + ): JsonSchema { + const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, options); + expect(code).not.toContain('createRequire'); + expect(code).not.toContain('__rushstackAjvRuntimeRequire'); + const generatedModule: { exports?: IJsonSchemaCompiledValidator } = {}; + const standaloneRequire = (specifier: string): unknown => { + if (specifier !== '@rushstack/node-core-library/lib/JsonSchemaRuntime') { + throw new Error(`Unexpected dependency in generated code: ${specifier}`); + } + return require(specifier); + }; + runInNewContext(code, { module: generatedModule, require: standaloneRequire }); + expect(typeof generatedModule.exports).toBe('function'); + return JsonSchema.fromCompiledValidator(generatedModule.exports!); + } + + test.each([DRAFT_04_SCHEMA_PATH, DRAFT_07_SCHEMA_PATH])( + 'validates formats and inferred draft version for %s', + (filename) => { + const standaloneSchema: JsonSchema = loadStandaloneSchema(filename); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }, + 'input.json' + ) + ).not.toThrow(); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'hello', exampleArray: [], exampleLink: 'not a URI' }, + 'input.json' + ) + ).toThrow(/must match format "uri"/); + } + ); + + test.each([ + DRAFT_04_SCHEMA_PATH, + DRAFT_07_SCHEMA_PATH, + `${__dirname}/test-data/test-schemas/test-schema-string-length.schema.json` + ])( + 'emits executable ESM with static AJV imports for %s', + (filename) => { + const code: string = JsonSchema.compileStandaloneCodeFromFile(filename, undefined, { + moduleFormat: 'esm' + }); + expect(code).toMatch( + /^import \{ [^}]+ \} from "@rushstack\/node-core-library\/lib\/JsonSchemaRuntime";/m + ); + expect(code).not.toMatch(/from "ajv(?:-formats)?\//); + expect(code).toMatch(/export default validate\d+;/); + expect(code).not.toMatch(/\brequire\s*\(|\bmodule\.exports\b|createRequire/); + + const validatorName: string = code.match(/export default (validate\d+);/)![1]; + const result: string = execFileSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `${code} +const valid = { exampleString: 'hello', exampleArray: [], exampleLink: 'https://example.com' }; +const invalid = { ...valid, exampleLink: 'not a URI' }; +const sample = ${JSON.stringify('test-schema-string-length.schema.json')}; +if (${JSON.stringify(filename)}.endsWith(sample)) { + if (!${validatorName}({ value: 'ab' })) throw new Error('Valid string rejected'); + if (${validatorName}({ value: 'a' })) throw new Error('Short string accepted'); + if (!${validatorName}.errors?.some(error => error.keyword === 'minLength')) throw new Error('Missing length error'); +} else { + if (!${validatorName}(valid)) throw new Error('Valid input rejected'); + if (${validatorName}(invalid)) throw new Error('Invalid URI accepted'); + if (!${validatorName}.errors?.some(error => error.keyword === 'format')) throw new Error('Missing format error'); + if (${validatorName}({ ...valid, exampleUniqueObjectArray: [{ field2: 'a' }, { field2: 'a' }] })) { + throw new Error('Duplicate objects accepted'); + } +}` + ], + { cwd: path.resolve(__dirname, '../..'), encoding: 'utf8' } + ); + expect(result).toBe(''); + } + ); + + test('resolves local $ref schemas and accepts vendor keywords', () => { + const standaloneSchema: JsonSchema = loadStandaloneSchema( + `${__dirname}/test-data/test-schemas/test-schema-standalone.schema.json` + ); + expect(() => standaloneSchema.validateObject({ item: { field1: 'valid' } }, 'input.json')).not.toThrow(); + expect(() => standaloneSchema.validateObject({ item: {} }, 'input.json')).toThrow( + /must have required property 'field1'/ + ); + }); + + test('resolves external $ref schemas supplied as dependentSchemas', () => { + const childSchema: JsonSchema = JsonSchema.fromFile( + `${__dirname}/test-data/test-schemas/test-schema-nested-child.schema.json` + ); + const standaloneSchema: JsonSchema = loadStandaloneSchema( + `${__dirname}/test-data/test-schemas/test-schema-nested.schema.json`, + { dependentSchemas: [childSchema] } + ); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'valid', exampleArray: [], exampleUniqueObjectArray: [{ field2: 'a', field3: 'b' }] }, + 'input.json' + ) + ).not.toThrow(); + expect(() => + standaloneSchema.validateObject( + { exampleString: 'invalid', exampleArray: [], exampleUniqueObjectArray: [{ field2: 'a' }] }, + 'input.json' + ) + ).toThrow(/must have required property 'field3'/); + }); + + test('rejects custom format validation functions, which cannot be serialized', () => { + expect(() => + JsonSchema.compileStandaloneCodeFromFile(DRAFT_07_SCHEMA_PATH, { + customFormats: { custom: { type: 'string', validate: (value) => value.length > 0 } } + }) + ).toThrow(/does not support customFormats validation functions/); + }); + }); + test('accepts vendor extension keywords by default', () => { const schemaWithVendorExtensions: JsonSchema = JsonSchema.fromLoadedObject( { diff --git a/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json new file mode 100644 index 00000000000..c3fabf11e28 --- /dev/null +++ b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-standalone.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "http://example.com/schemas/test-schema-standalone.schema.json", + "x-myvendor-description": "Standalone schema", + "type": "object", + "definitions": { + "type1": { + "type": "object", + "properties": { + "field1": { "type": "string" } + }, + "required": ["field1"] + } + }, + "properties": { + "item": { "$ref": "#/definitions/type1" } + }, + "required": ["item"] +} diff --git a/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json new file mode 100644 index 00000000000..340b66d23e5 --- /dev/null +++ b/libraries/node-core-library/src/test/test-data/test-schemas/test-schema-string-length.schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "value": { "type": "string", "minLength": 2 } + }, + "required": ["value"] +}