Skip to content

Commit c5ddb60

Browse files
feat: support schema merging (#237)
* feat(merge-schemas): support mergeSchemas rule option This option controlls whether schemas from different sources are merged and combined together. Can be an array of ["$schema", "options", "catalog"] or true as shorthand for all three. Closes #235 * test(merge-schema): add test cases for the mergeSchemas option * docs(merge-schemas): document the mergeSchemas option * Create fluffy-jeans-whisper.md * Update fluffy-jeans-whisper.md --------- Co-authored-by: Yosuke Ota <otameshiyo23@gmail.com>
1 parent 80def43 commit c5ddb60

File tree

4 files changed

+217
-106
lines changed

4 files changed

+217
-106
lines changed

.changeset/fluffy-jeans-whisper.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-json-schema-validator": minor
3+
---
4+
5+
feat: support schema merging

docs/rules/no-invalid.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ This rule validates the file with JSON Schema and reports errors.
5454
"schema": {/* JSON Schema Definition */} // or string
5555
}
5656
],
57-
"useSchemastoreCatalog": true
57+
"useSchemastoreCatalog": true,
58+
"mergeSchemas": true // or ["$schema", "options", "catalog"]
5859
}
5960
]
6061
}
@@ -64,6 +65,7 @@ This rule validates the file with JSON Schema and reports errors.
6465
- `fileMatch` ... A list of known file names (or globs) that match the schema.
6566
- `schema` ... An object that defines a JSON schema. Or the path of the JSON schema file or URL.
6667
- `useSchemastoreCatalog` ... If `true`, it will automatically configure some schemas defined in [https://www.schemastore.org/api/json/catalog.json](https://www.schemastore.org/api/json/catalog.json). Default `true`
68+
- `mergeSchemas` ... If `true`, it will merge all schemas defined in `schemas`, at the `$schema` field within files, and the catalogue. If an array is given, it will merge only schemas from the given sources. Default `false`
6769

6870
This option can also be given a JSON schema file or URL. This is useful for configuring with the `/* eslint */` directive comments.
6971

src/rules/no-invalid.ts

Lines changed: 148 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -39,89 +39,6 @@ function matchFile(filename: string, fileMatch: string[]) {
3939
);
4040
}
4141

42-
/**
43-
* Parse option
44-
*/
45-
function parseOption(
46-
option:
47-
| {
48-
schemas?: {
49-
name?: string;
50-
description?: string;
51-
fileMatch: string[];
52-
schema: SchemaObject | string;
53-
}[];
54-
useSchemastoreCatalog?: boolean;
55-
}
56-
| string,
57-
context: RuleContext,
58-
filename: string,
59-
): Validator | null {
60-
if (typeof option === "string") {
61-
return schemaPathToValidator(option, context);
62-
}
63-
64-
const validators: Validator[] = [];
65-
66-
for (const schemaData of option.schemas || []) {
67-
if (!matchFile(filename, schemaData.fileMatch)) {
68-
continue;
69-
}
70-
if (typeof schemaData.schema === "string") {
71-
const validator = schemaPathToValidator(schemaData.schema, context);
72-
if (validator) {
73-
validators.push(validator);
74-
} else {
75-
reportCannotResolvedPath(schemaData.schema, context);
76-
}
77-
} else {
78-
const validator = schemaObjectToValidator(schemaData.schema, context);
79-
if (validator) {
80-
validators.push(validator);
81-
} else {
82-
reportCannotResolvedObject(context);
83-
}
84-
}
85-
}
86-
if (!validators.length) {
87-
// If it matches the user's definition, don't use `catalog.json`.
88-
if (option.useSchemastoreCatalog !== false) {
89-
const catalog = loadJson(CATALOG_URL, context);
90-
if (!catalog) {
91-
return null;
92-
}
93-
94-
const schemas: {
95-
name?: string;
96-
description?: string;
97-
fileMatch: string[];
98-
url: string;
99-
}[] = catalog.schemas;
100-
101-
for (const schemaData of schemas) {
102-
if (!schemaData.fileMatch) {
103-
continue;
104-
}
105-
if (!matchFile(filename, schemaData.fileMatch)) {
106-
continue;
107-
}
108-
const validator = schemaPathToValidator(schemaData.url, context);
109-
if (validator) validators.push(validator);
110-
}
111-
}
112-
}
113-
if (!validators.length) {
114-
return null;
115-
}
116-
return (data) => {
117-
const errors: ValidateError[] = [];
118-
for (const validator of validators) {
119-
errors.push(...validator(data));
120-
}
121-
return errors;
122-
};
123-
}
124-
12542
/**
12643
* Generate validator from schema path
12744
*/
@@ -170,6 +87,13 @@ function reportCannotResolvedObject(context: RuleContext) {
17087
});
17188
}
17289

90+
/** Get mergeSchemas option */
91+
function parseMergeSchemasOption(
92+
option: boolean | string[] | undefined,
93+
): string[] | null {
94+
return option === true ? ["$schema", "catalog", "options"] : option || null;
95+
}
96+
17397
export default createRule("no-invalid", {
17498
meta: {
17599
docs: {
@@ -204,6 +128,18 @@ export default createRule("no-invalid", {
204128
},
205129
},
206130
useSchemastoreCatalog: { type: "boolean" },
131+
mergeSchemas: {
132+
oneOf: [
133+
{ type: "boolean" },
134+
{
135+
type: "array",
136+
items: {
137+
type: "string",
138+
enum: ["$schema", "catalog", "options"],
139+
},
140+
},
141+
],
142+
},
207143
},
208144
additionalProperties: false,
209145
},
@@ -214,27 +150,12 @@ export default createRule("no-invalid", {
214150
type: "suggestion",
215151
},
216152
create(context, { filename }) {
217-
const $schemaPath = findSchemaPath(context.getSourceCode().ast);
218-
let validator: Validator;
219-
if ($schemaPath != null) {
220-
const v = schemaPathToValidator($schemaPath, context);
221-
if (!v) {
222-
reportCannotResolvedPath($schemaPath, context);
223-
return {};
224-
}
225-
validator = v;
226-
} else {
227-
const cwd = getCwd(context);
228-
const v = parseOption(
229-
context.options[0] || {},
230-
context,
231-
filename.startsWith(cwd) ? path.relative(cwd, filename) : filename,
232-
);
233-
if (!v) {
234-
return {};
235-
}
236-
validator = v;
237-
}
153+
const cwd = getCwd(context);
154+
const relativeFilename = filename.startsWith(cwd)
155+
? path.relative(cwd, filename)
156+
: filename;
157+
158+
const validator = createValidator(context, relativeFilename);
238159

239160
let existsExports = false;
240161
const sourceCode = context.getSourceCode();
@@ -246,7 +167,7 @@ export default createRule("no-invalid", {
246167
data: unknown,
247168
resolveLoc: (error: ValidateError) => JSONAST.SourceLocation | null,
248169
) {
249-
const errors = validator!(data);
170+
const errors = validator(data);
250171
for (const error of errors) {
251172
const loc = resolveLoc(error);
252173

@@ -446,6 +367,128 @@ export default createRule("no-invalid", {
446367
: $schema
447368
: null;
448369
}
370+
371+
/** Validator from $schema */
372+
function get$SchemaValidators(context: RuleContext): Validator[] | null {
373+
const $schemaPath = findSchemaPath(context.getSourceCode().ast);
374+
if (!$schemaPath) return null;
375+
376+
const validator = schemaPathToValidator($schemaPath, context);
377+
if (!validator) {
378+
reportCannotResolvedPath($schemaPath, context);
379+
return null;
380+
}
381+
382+
return [validator];
383+
}
384+
385+
/** Validator from catalog.json */
386+
function getCatalogValidators(
387+
context: RuleContext,
388+
relativeFilename: string,
389+
): Validator[] | null {
390+
const option = context.options[0] || {};
391+
if (!option.useSchemastoreCatalog) {
392+
return null;
393+
}
394+
395+
interface ISchema {
396+
name?: string;
397+
description?: string;
398+
fileMatch: string[];
399+
url: string;
400+
}
401+
const catalog = loadJson<{ schemas: ISchema[] }>(CATALOG_URL, context);
402+
if (!catalog) {
403+
return null;
404+
}
405+
406+
const validators: Validator[] = [];
407+
for (const schemaData of catalog.schemas) {
408+
if (!schemaData.fileMatch) {
409+
continue;
410+
}
411+
if (!matchFile(relativeFilename, schemaData.fileMatch)) {
412+
continue;
413+
}
414+
const validator = schemaPathToValidator(schemaData.url, context);
415+
if (validator) validators.push(validator);
416+
}
417+
return validators.length ? validators : null;
418+
}
419+
420+
/** Validator from options.schemas */
421+
function getOptionsValidators(
422+
context: RuleContext,
423+
filename: string,
424+
): Validator[] | null {
425+
const option = context.options[0];
426+
if (typeof option === "string") {
427+
const validator = schemaPathToValidator(option, context);
428+
return validator ? [validator] : null;
429+
}
430+
431+
if (typeof option !== "object" || !Array.isArray(option.schemas)) {
432+
return null;
433+
}
434+
435+
const validators: Validator[] = [];
436+
for (const schemaData of option.schemas) {
437+
if (!matchFile(filename, schemaData.fileMatch)) {
438+
continue;
439+
}
440+
441+
if (typeof schemaData.schema === "string") {
442+
const validator = schemaPathToValidator(schemaData.schema, context);
443+
if (validator) {
444+
validators.push(validator);
445+
} else {
446+
reportCannotResolvedPath(schemaData.schema, context);
447+
}
448+
} else {
449+
const validator = schemaObjectToValidator(schemaData.schema, context);
450+
if (validator) {
451+
validators.push(validator);
452+
} else {
453+
reportCannotResolvedObject(context);
454+
}
455+
}
456+
}
457+
return validators.length ? validators : null;
458+
}
459+
460+
/** Create combined validator */
461+
function createValidator(context: RuleContext, filename: string) {
462+
const mergeSchemas = parseMergeSchemasOption(
463+
context.options[0]?.mergeSchemas,
464+
);
465+
466+
const validators: Validator[] = [];
467+
if (mergeSchemas) {
468+
if (mergeSchemas.includes("$schema")) {
469+
validators.push(...(get$SchemaValidators(context) || []));
470+
}
471+
if (mergeSchemas.includes("options")) {
472+
validators.push(...(getOptionsValidators(context, filename) || []));
473+
}
474+
if (mergeSchemas.includes("catalog")) {
475+
validators.push(...(getCatalogValidators(context, filename) || []));
476+
}
477+
} else {
478+
validators.push(
479+
...(get$SchemaValidators(context) ||
480+
getOptionsValidators(context, filename) ||
481+
getCatalogValidators(context, filename) ||
482+
[]),
483+
);
484+
}
485+
486+
return (data: unknown) =>
487+
validators.reduce(
488+
(errors, validator) => [...errors, ...validator(data)],
489+
[] as ValidateError[],
490+
);
491+
}
449492
},
450493
});
451494

tests/src/rules/no-invalid.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,67 @@ tester.run(
7777
'"extends[0]" must be string.',
7878
],
7979
},
80+
{
81+
filename: path.join(__dirname, ".eslintrc.json"),
82+
code: '{ "extends": [ 42 ], "$schema": "https://json.schemastore.org/eslintrc" }',
83+
options: [
84+
{
85+
schemas: [
86+
{
87+
fileMatch: ["tests/src/rules/.eslintrc.json"],
88+
schema: {
89+
properties: {
90+
foo: {
91+
type: "number",
92+
},
93+
},
94+
required: ["foo"],
95+
},
96+
},
97+
],
98+
mergeSchemas: true,
99+
useSchemastoreCatalog: false,
100+
},
101+
],
102+
errors: [
103+
"Root must have required property 'foo'.",
104+
'"extends" must be string.',
105+
'"extends" must match exactly one schema in oneOf.',
106+
'"extends[0]" must be string.',
107+
],
108+
},
109+
{
110+
filename: path.join(__dirname, "version.json"),
111+
code: '{ "extends": [ 42 ], "$schema": "https://json.schemastore.org/eslintrc" }',
112+
options: [
113+
{
114+
schemas: [
115+
{
116+
fileMatch: ["tests/src/rules/version.json"],
117+
schema: {
118+
properties: {
119+
foo: {
120+
type: "number",
121+
},
122+
},
123+
required: ["foo"],
124+
},
125+
},
126+
],
127+
mergeSchemas: true,
128+
useSchemastoreCatalog: true,
129+
},
130+
],
131+
errors: [
132+
"Root must have required property 'foo'.",
133+
"Root must have required property 'version'.",
134+
"Root must have required property 'inherit'.",
135+
"Root must match a schema in anyOf.",
136+
'"extends" must be string.',
137+
'"extends" must match exactly one schema in oneOf.',
138+
'"extends[0]" must be string.',
139+
],
140+
},
80141
{
81142
filename: path.join(__dirname, ".prettierrc.toml"),
82143
code: `

0 commit comments

Comments
 (0)