diff --git a/README.md b/README.md index 1d501da..c806d39 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ jobs: files: .github/workflows/**.yml ``` +### Validating Schema + +Schemas can be validated by setting the `schema` input to the string literal +`json-schema`. + ### Remote Schema Cache Busting By default the action will cache remote schemas (this can be disabled via the diff --git a/__tests__/fixtures/invalid.schema.json b/__tests__/fixtures/invalid.schema.json new file mode 100644 index 0000000..549ec77 --- /dev/null +++ b/__tests__/fixtures/invalid.schema.json @@ -0,0 +1,11 @@ +{ + "title": "Invalid JSON schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "foobar": { + "type": "string", + "minLength": "foo" + } + } +} diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 03b68e4..55fd3c6 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -31,6 +31,12 @@ describe('action', () => { path.join(__dirname, 'fixtures', 'evm-config.schema.json'), 'utf-8' ); + const invalidSchemaContents: string = jest + .requireActual('node:fs') + .readFileSync( + path.join(__dirname, 'fixtures', 'invalid.schema.json'), + 'utf-8' + ); const instanceContents: string = jest .requireActual('node:fs') .readFileSync(path.join(__dirname, 'fixtures', 'evm-config.yml'), 'utf-8'); @@ -419,4 +425,70 @@ describe('action', () => { expect(core.setOutput).toHaveBeenCalledTimes(1); expect(core.setOutput).toHaveBeenLastCalledWith('valid', true); }); + + describe('can validate schemas', () => { + beforeEach(() => { + mockGetBooleanInput({}); + mockGetInput({ schema: 'json-schema' }); + mockGetMultilineInput({ files }); + + mockGlobGenerator(['/foo/bar/baz/config.yml']); + }); + + it('which are valid', async () => { + jest.mocked(fs.readFile).mockResolvedValueOnce(schemaContents); + + await main.run(); + expect(runSpy).toHaveReturned(); + expect(process.exitCode).not.toBeDefined(); + + expect(core.setOutput).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenLastCalledWith('valid', true); + }); + + it('which are invalid', async () => { + mockGetBooleanInput({ 'fail-on-invalid': false }); + + jest.mocked(fs.readFile).mockResolvedValueOnce(invalidSchemaContents); + + await main.run(); + expect(runSpy).toHaveReturned(); + expect(process.exitCode).not.toBeDefined(); + + expect(core.setOutput).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenLastCalledWith('valid', false); + }); + + it('using JSON Schema draft-04', async () => { + jest + .mocked(fs.readFile) + .mockResolvedValueOnce( + schemaContents.replace( + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-04/schema#' + ) + ); + + await main.run(); + expect(runSpy).toHaveReturned(); + expect(process.exitCode).not.toBeDefined(); + + expect(core.setOutput).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenLastCalledWith('valid', true); + }); + + it('but fails if $schema key is missing', async () => { + jest + .mocked(fs.readFile) + .mockResolvedValueOnce(schemaContents.replace('$schema', '_schema')); + + await main.run(); + expect(runSpy).toHaveReturned(); + expect(process.exitCode).not.toBeDefined(); + + expect(core.setFailed).toHaveBeenLastCalledWith( + 'JSON schema missing $schema key' + ); + }); + }); }); diff --git a/dist/index.js b/dist/index.js index fff098f..758c35c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -89626,6 +89626,17 @@ const _2019_1 = __importDefault(__nccwpck_require__(5988)); const ajv_draft_04_1 = __importDefault(__nccwpck_require__(7023)); const ajv_formats_1 = __importDefault(__nccwpck_require__(567)); const yaml = __importStar(__nccwpck_require__(4083)); +function newAjv(schema) { + const draft04Schema = schema.$schema === 'http://json-schema.org/draft-04/schema#'; + const ajv = (0, ajv_formats_1.default)(draft04Schema ? new ajv_draft_04_1.default() : new _2019_1.default()); + if (!draft04Schema) { + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + ajv.addMetaSchema(__nccwpck_require__(18)); + ajv.addMetaSchema(__nccwpck_require__(98)); + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + } + return ajv; +} /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. @@ -89636,7 +89647,6 @@ async function run() { const files = core.getMultilineInput('files', { required: true }); const cacheRemoteSchema = core.getBooleanInput('cache-remote-schema'); const failOnInvalid = core.getBooleanInput('fail-on-invalid'); - // TODO - Handle special case where schemaPath === 'json-schema', then use ajv.validateSchema // Fetch and cache remote schemas if (schemaPath.startsWith('http://') || schemaPath.startsWith('https://')) { const schemaUrl = schemaPath; @@ -89674,34 +89684,46 @@ async function run() { } } } - // Load and compile the schema - const schema = JSON.parse(await fs.readFile(schemaPath, 'utf-8')); - if (typeof schema.$schema !== 'string') { - core.setFailed('JSON schema missing $schema key'); - return; + const validatingSchema = schemaPath === 'json-schema'; + let validate; + if (validatingSchema) { + validate = async (data) => { + // Create a new Ajv instance per-schema since + // they may require different draft versions + const ajv = newAjv(data); + await ajv.validateSchema(data); + return ajv.errors || []; + }; } - const draft04Schema = schema.$schema === 'http://json-schema.org/draft-04/schema#'; - const ajv = draft04Schema ? new ajv_draft_04_1.default() : new _2019_1.default(); - if (!draft04Schema) { - /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ - ajv.addMetaSchema(__nccwpck_require__(18)); - ajv.addMetaSchema(__nccwpck_require__(98)); - /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + else { + // Load and compile the schema + const schema = JSON.parse(await fs.readFile(schemaPath, 'utf-8')); + if (typeof schema.$schema !== 'string') { + core.setFailed('JSON schema missing $schema key'); + return; + } + const ajv = newAjv(schema); + validate = async (data) => { + ajv.validate(schema, data); + return ajv.errors || []; + }; } - const validate = (0, ajv_formats_1.default)(ajv).compile(schema); let valid = true; let filesValidated = false; const globber = await glob.create(files.join('\n')); for await (const file of globber.globGenerator()) { filesValidated = true; const instance = yaml.parse(await fs.readFile(file, 'utf-8')); - if (!validate(instance)) { + if (validatingSchema && typeof instance.$schema !== 'string') { + core.setFailed('JSON schema missing $schema key'); + return; + } + const errors = await validate(instance); + if (errors.length) { valid = false; core.debug(`𐄂 ${file} is not valid`); - if (validate.errors) { - for (const error of validate.errors) { - core.error(JSON.stringify(error, null, 4)); - } + for (const error of errors) { + core.error(JSON.stringify(error, null, 4)); } } else { diff --git a/src/main.ts b/src/main.ts index 61bdb33..3bb6f32 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,11 +7,28 @@ import * as core from '@actions/core'; import * as glob from '@actions/glob'; import * as http from '@actions/http-client'; -import Ajv2019 from 'ajv/dist/2019'; +import type { default as Ajv } from 'ajv'; +import { default as Ajv2019, ErrorObject } from 'ajv/dist/2019'; import AjvDraft04 from 'ajv-draft-04'; import AjvFormats from 'ajv-formats'; import * as yaml from 'yaml'; +function newAjv(schema: Record): Ajv { + const draft04Schema = + schema.$schema === 'http://json-schema.org/draft-04/schema#'; + + const ajv = AjvFormats(draft04Schema ? new AjvDraft04() : new Ajv2019()); + + if (!draft04Schema) { + /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json')); + ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json')); + /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + } + + return ajv; +} + /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. @@ -72,30 +89,40 @@ export async function run(): Promise { } } - // Load and compile the schema - const schema: Record = JSON.parse( - await fs.readFile(schemaPath, 'utf-8') - ); - - if (typeof schema.$schema !== 'string') { - core.setFailed('JSON schema missing $schema key'); - return; - } + const validatingSchema = schemaPath === 'json-schema'; + + let validate: ( + data: Record + ) => Promise, unknown>[]>; + + if (validatingSchema) { + validate = async (data: Record) => { + // Create a new Ajv instance per-schema since + // they may require different draft versions + const ajv = newAjv(data); + + await ajv.validateSchema(data); + return ajv.errors || []; + }; + } else { + // Load and compile the schema + const schema: Record = JSON.parse( + await fs.readFile(schemaPath, 'utf-8') + ); - const draft04Schema = - schema.$schema === 'http://json-schema.org/draft-04/schema#'; + if (typeof schema.$schema !== 'string') { + core.setFailed('JSON schema missing $schema key'); + return; + } - const ajv = draft04Schema ? new AjvDraft04() : new Ajv2019(); + const ajv = newAjv(schema); - if (!draft04Schema) { - /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ - ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json')); - ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json')); - /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ + validate = async (data: object) => { + ajv.validate(schema, data); + return ajv.errors || []; + }; } - const validate = AjvFormats(ajv).compile(schema); - let valid = true; let filesValidated = false; @@ -103,16 +130,22 @@ export async function run(): Promise { for await (const file of globber.globGenerator()) { filesValidated = true; + const instance = yaml.parse(await fs.readFile(file, 'utf-8')); - if (!validate(instance)) { + if (validatingSchema && typeof instance.$schema !== 'string') { + core.setFailed('JSON schema missing $schema key'); + return; + } + + const errors = await validate(instance); + + if (errors.length) { valid = false; core.debug(`𐄂 ${file} is not valid`); - if (validate.errors) { - for (const error of validate.errors) { - core.error(JSON.stringify(error, null, 4)); - } + for (const error of errors) { + core.error(JSON.stringify(error, null, 4)); } } else { core.debug(`✓ ${file} is valid`);