Skip to content

Commit

Permalink
feat: validate schemas as well
Browse files Browse the repository at this point in the history
  • Loading branch information
dsanders11 committed Dec 30, 2023
1 parent 9725851 commit 5f3437c
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 44 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions __tests__/fixtures/invalid.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"title": "Invalid JSON schema",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"foobar": {
"type": "string",
"minLength": "foo"
}
}
}
72 changes: 72 additions & 0 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'
);
});
});
});
60 changes: 41 additions & 19 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 58 additions & 25 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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<void>} Resolves when the action is complete.
Expand Down Expand Up @@ -72,47 +89,63 @@ export async function run(): Promise<void> {
}
}

// Load and compile the schema
const schema: Record<string, unknown> = 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<string, unknown>
) => Promise<ErrorObject<string, Record<string, unknown>, unknown>[]>;

if (validatingSchema) {
validate = async (data: Record<string, unknown>) => {
// 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<string, unknown> = 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;

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 {
core.debug(`✓ ${file} is valid`);
Expand Down

0 comments on commit 5f3437c

Please sign in to comment.