Skip to content

Commit 5f3437c

Browse files
committed
feat: validate schemas as well
1 parent 9725851 commit 5f3437c

File tree

5 files changed

+187
-44
lines changed

5 files changed

+187
-44
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ jobs:
3131
files: .github/workflows/**.yml
3232
```
3333
34+
### Validating Schema
35+
36+
Schemas can be validated by setting the `schema` input to the string literal
37+
`json-schema`.
38+
3439
### Remote Schema Cache Busting
3540

3641
By default the action will cache remote schemas (this can be disabled via the
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"title": "Invalid JSON schema",
3+
"$schema": "http://json-schema.org/draft-07/schema#",
4+
"type": "object",
5+
"properties": {
6+
"foobar": {
7+
"type": "string",
8+
"minLength": "foo"
9+
}
10+
}
11+
}

__tests__/main.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ describe('action', () => {
3131
path.join(__dirname, 'fixtures', 'evm-config.schema.json'),
3232
'utf-8'
3333
);
34+
const invalidSchemaContents: string = jest
35+
.requireActual('node:fs')
36+
.readFileSync(
37+
path.join(__dirname, 'fixtures', 'invalid.schema.json'),
38+
'utf-8'
39+
);
3440
const instanceContents: string = jest
3541
.requireActual('node:fs')
3642
.readFileSync(path.join(__dirname, 'fixtures', 'evm-config.yml'), 'utf-8');
@@ -419,4 +425,70 @@ describe('action', () => {
419425
expect(core.setOutput).toHaveBeenCalledTimes(1);
420426
expect(core.setOutput).toHaveBeenLastCalledWith('valid', true);
421427
});
428+
429+
describe('can validate schemas', () => {
430+
beforeEach(() => {
431+
mockGetBooleanInput({});
432+
mockGetInput({ schema: 'json-schema' });
433+
mockGetMultilineInput({ files });
434+
435+
mockGlobGenerator(['/foo/bar/baz/config.yml']);
436+
});
437+
438+
it('which are valid', async () => {
439+
jest.mocked(fs.readFile).mockResolvedValueOnce(schemaContents);
440+
441+
await main.run();
442+
expect(runSpy).toHaveReturned();
443+
expect(process.exitCode).not.toBeDefined();
444+
445+
expect(core.setOutput).toHaveBeenCalledTimes(1);
446+
expect(core.setOutput).toHaveBeenLastCalledWith('valid', true);
447+
});
448+
449+
it('which are invalid', async () => {
450+
mockGetBooleanInput({ 'fail-on-invalid': false });
451+
452+
jest.mocked(fs.readFile).mockResolvedValueOnce(invalidSchemaContents);
453+
454+
await main.run();
455+
expect(runSpy).toHaveReturned();
456+
expect(process.exitCode).not.toBeDefined();
457+
458+
expect(core.setOutput).toHaveBeenCalledTimes(1);
459+
expect(core.setOutput).toHaveBeenLastCalledWith('valid', false);
460+
});
461+
462+
it('using JSON Schema draft-04', async () => {
463+
jest
464+
.mocked(fs.readFile)
465+
.mockResolvedValueOnce(
466+
schemaContents.replace(
467+
'http://json-schema.org/draft-07/schema#',
468+
'http://json-schema.org/draft-04/schema#'
469+
)
470+
);
471+
472+
await main.run();
473+
expect(runSpy).toHaveReturned();
474+
expect(process.exitCode).not.toBeDefined();
475+
476+
expect(core.setOutput).toHaveBeenCalledTimes(1);
477+
expect(core.setOutput).toHaveBeenLastCalledWith('valid', true);
478+
});
479+
480+
it('but fails if $schema key is missing', async () => {
481+
jest
482+
.mocked(fs.readFile)
483+
.mockResolvedValueOnce(schemaContents.replace('$schema', '_schema'));
484+
485+
await main.run();
486+
expect(runSpy).toHaveReturned();
487+
expect(process.exitCode).not.toBeDefined();
488+
489+
expect(core.setFailed).toHaveBeenLastCalledWith(
490+
'JSON schema missing $schema key'
491+
);
492+
});
493+
});
422494
});

dist/index.js

Lines changed: 41 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,28 @@ import * as core from '@actions/core';
77
import * as glob from '@actions/glob';
88
import * as http from '@actions/http-client';
99

10-
import Ajv2019 from 'ajv/dist/2019';
10+
import type { default as Ajv } from 'ajv';
11+
import { default as Ajv2019, ErrorObject } from 'ajv/dist/2019';
1112
import AjvDraft04 from 'ajv-draft-04';
1213
import AjvFormats from 'ajv-formats';
1314
import * as yaml from 'yaml';
1415

16+
function newAjv(schema: Record<string, unknown>): Ajv {
17+
const draft04Schema =
18+
schema.$schema === 'http://json-schema.org/draft-04/schema#';
19+
20+
const ajv = AjvFormats(draft04Schema ? new AjvDraft04() : new Ajv2019());
21+
22+
if (!draft04Schema) {
23+
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
24+
ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json'));
25+
ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json'));
26+
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
27+
}
28+
29+
return ajv;
30+
}
31+
1532
/**
1633
* The main function for the action.
1734
* @returns {Promise<void>} Resolves when the action is complete.
@@ -72,47 +89,63 @@ export async function run(): Promise<void> {
7289
}
7390
}
7491

75-
// Load and compile the schema
76-
const schema: Record<string, unknown> = JSON.parse(
77-
await fs.readFile(schemaPath, 'utf-8')
78-
);
79-
80-
if (typeof schema.$schema !== 'string') {
81-
core.setFailed('JSON schema missing $schema key');
82-
return;
83-
}
92+
const validatingSchema = schemaPath === 'json-schema';
93+
94+
let validate: (
95+
data: Record<string, unknown>
96+
) => Promise<ErrorObject<string, Record<string, unknown>, unknown>[]>;
97+
98+
if (validatingSchema) {
99+
validate = async (data: Record<string, unknown>) => {
100+
// Create a new Ajv instance per-schema since
101+
// they may require different draft versions
102+
const ajv = newAjv(data);
103+
104+
await ajv.validateSchema(data);
105+
return ajv.errors || [];
106+
};
107+
} else {
108+
// Load and compile the schema
109+
const schema: Record<string, unknown> = JSON.parse(
110+
await fs.readFile(schemaPath, 'utf-8')
111+
);
84112

85-
const draft04Schema =
86-
schema.$schema === 'http://json-schema.org/draft-04/schema#';
113+
if (typeof schema.$schema !== 'string') {
114+
core.setFailed('JSON schema missing $schema key');
115+
return;
116+
}
87117

88-
const ajv = draft04Schema ? new AjvDraft04() : new Ajv2019();
118+
const ajv = newAjv(schema);
89119

90-
if (!draft04Schema) {
91-
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
92-
ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json'));
93-
ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json'));
94-
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
120+
validate = async (data: object) => {
121+
ajv.validate(schema, data);
122+
return ajv.errors || [];
123+
};
95124
}
96125

97-
const validate = AjvFormats(ajv).compile(schema);
98-
99126
let valid = true;
100127
let filesValidated = false;
101128

102129
const globber = await glob.create(files.join('\n'));
103130

104131
for await (const file of globber.globGenerator()) {
105132
filesValidated = true;
133+
106134
const instance = yaml.parse(await fs.readFile(file, 'utf-8'));
107135

108-
if (!validate(instance)) {
136+
if (validatingSchema && typeof instance.$schema !== 'string') {
137+
core.setFailed('JSON schema missing $schema key');
138+
return;
139+
}
140+
141+
const errors = await validate(instance);
142+
143+
if (errors.length) {
109144
valid = false;
110145
core.debug(`𐄂 ${file} is not valid`);
111146

112-
if (validate.errors) {
113-
for (const error of validate.errors) {
114-
core.error(JSON.stringify(error, null, 4));
115-
}
147+
for (const error of errors) {
148+
core.error(JSON.stringify(error, null, 4));
116149
}
117150
} else {
118151
core.debug(`✓ ${file} is valid`);

0 commit comments

Comments
 (0)