Skip to content

Commit

Permalink
feat: support schema merging (#237)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
MarcRoemmelt and ota-meshi committed Jul 29, 2023
1 parent 80def43 commit c5ddb60
Show file tree
Hide file tree
Showing 4 changed files with 217 additions and 106 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-jeans-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-json-schema-validator": minor
---

feat: support schema merging
4 changes: 3 additions & 1 deletion docs/rules/no-invalid.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ This rule validates the file with JSON Schema and reports errors.
"schema": {/* JSON Schema Definition */} // or string
}
],
"useSchemastoreCatalog": true
"useSchemastoreCatalog": true,
"mergeSchemas": true // or ["$schema", "options", "catalog"]
}
]
}
Expand All @@ -64,6 +65,7 @@ This rule validates the file with JSON Schema and reports errors.
- `fileMatch` ... A list of known file names (or globs) that match the schema.
- `schema` ... An object that defines a JSON schema. Or the path of the JSON schema file or URL.
- `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`
- `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`

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

Expand Down
253 changes: 148 additions & 105 deletions src/rules/no-invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,89 +39,6 @@ function matchFile(filename: string, fileMatch: string[]) {
);
}

/**
* Parse option
*/
function parseOption(
option:
| {
schemas?: {
name?: string;
description?: string;
fileMatch: string[];
schema: SchemaObject | string;
}[];
useSchemastoreCatalog?: boolean;
}
| string,
context: RuleContext,
filename: string,
): Validator | null {
if (typeof option === "string") {
return schemaPathToValidator(option, context);
}

const validators: Validator[] = [];

for (const schemaData of option.schemas || []) {
if (!matchFile(filename, schemaData.fileMatch)) {
continue;
}
if (typeof schemaData.schema === "string") {
const validator = schemaPathToValidator(schemaData.schema, context);
if (validator) {
validators.push(validator);
} else {
reportCannotResolvedPath(schemaData.schema, context);
}
} else {
const validator = schemaObjectToValidator(schemaData.schema, context);
if (validator) {
validators.push(validator);
} else {
reportCannotResolvedObject(context);
}
}
}
if (!validators.length) {
// If it matches the user's definition, don't use `catalog.json`.
if (option.useSchemastoreCatalog !== false) {
const catalog = loadJson(CATALOG_URL, context);
if (!catalog) {
return null;
}

const schemas: {
name?: string;
description?: string;
fileMatch: string[];
url: string;
}[] = catalog.schemas;

for (const schemaData of schemas) {
if (!schemaData.fileMatch) {
continue;
}
if (!matchFile(filename, schemaData.fileMatch)) {
continue;
}
const validator = schemaPathToValidator(schemaData.url, context);
if (validator) validators.push(validator);
}
}
}
if (!validators.length) {
return null;
}
return (data) => {
const errors: ValidateError[] = [];
for (const validator of validators) {
errors.push(...validator(data));
}
return errors;
};
}

/**
* Generate validator from schema path
*/
Expand Down Expand Up @@ -170,6 +87,13 @@ function reportCannotResolvedObject(context: RuleContext) {
});
}

/** Get mergeSchemas option */
function parseMergeSchemasOption(
option: boolean | string[] | undefined,
): string[] | null {
return option === true ? ["$schema", "catalog", "options"] : option || null;
}

export default createRule("no-invalid", {
meta: {
docs: {
Expand Down Expand Up @@ -204,6 +128,18 @@ export default createRule("no-invalid", {
},
},
useSchemastoreCatalog: { type: "boolean" },
mergeSchemas: {
oneOf: [
{ type: "boolean" },
{
type: "array",
items: {
type: "string",
enum: ["$schema", "catalog", "options"],
},
},
],
},
},
additionalProperties: false,
},
Expand All @@ -214,27 +150,12 @@ export default createRule("no-invalid", {
type: "suggestion",
},
create(context, { filename }) {
const $schemaPath = findSchemaPath(context.getSourceCode().ast);
let validator: Validator;
if ($schemaPath != null) {
const v = schemaPathToValidator($schemaPath, context);
if (!v) {
reportCannotResolvedPath($schemaPath, context);
return {};
}
validator = v;
} else {
const cwd = getCwd(context);
const v = parseOption(
context.options[0] || {},
context,
filename.startsWith(cwd) ? path.relative(cwd, filename) : filename,
);
if (!v) {
return {};
}
validator = v;
}
const cwd = getCwd(context);
const relativeFilename = filename.startsWith(cwd)
? path.relative(cwd, filename)
: filename;

const validator = createValidator(context, relativeFilename);

let existsExports = false;
const sourceCode = context.getSourceCode();
Expand All @@ -246,7 +167,7 @@ export default createRule("no-invalid", {
data: unknown,
resolveLoc: (error: ValidateError) => JSONAST.SourceLocation | null,
) {
const errors = validator!(data);
const errors = validator(data);
for (const error of errors) {
const loc = resolveLoc(error);

Expand Down Expand Up @@ -446,6 +367,128 @@ export default createRule("no-invalid", {
: $schema
: null;
}

/** Validator from $schema */
function get$SchemaValidators(context: RuleContext): Validator[] | null {
const $schemaPath = findSchemaPath(context.getSourceCode().ast);
if (!$schemaPath) return null;

const validator = schemaPathToValidator($schemaPath, context);
if (!validator) {
reportCannotResolvedPath($schemaPath, context);
return null;
}

return [validator];
}

/** Validator from catalog.json */
function getCatalogValidators(
context: RuleContext,
relativeFilename: string,
): Validator[] | null {
const option = context.options[0] || {};
if (!option.useSchemastoreCatalog) {
return null;
}

interface ISchema {
name?: string;
description?: string;
fileMatch: string[];
url: string;
}
const catalog = loadJson<{ schemas: ISchema[] }>(CATALOG_URL, context);
if (!catalog) {
return null;
}

const validators: Validator[] = [];
for (const schemaData of catalog.schemas) {
if (!schemaData.fileMatch) {
continue;
}
if (!matchFile(relativeFilename, schemaData.fileMatch)) {
continue;
}
const validator = schemaPathToValidator(schemaData.url, context);
if (validator) validators.push(validator);
}
return validators.length ? validators : null;
}

/** Validator from options.schemas */
function getOptionsValidators(
context: RuleContext,
filename: string,
): Validator[] | null {
const option = context.options[0];
if (typeof option === "string") {
const validator = schemaPathToValidator(option, context);
return validator ? [validator] : null;
}

if (typeof option !== "object" || !Array.isArray(option.schemas)) {
return null;
}

const validators: Validator[] = [];
for (const schemaData of option.schemas) {
if (!matchFile(filename, schemaData.fileMatch)) {
continue;
}

if (typeof schemaData.schema === "string") {
const validator = schemaPathToValidator(schemaData.schema, context);
if (validator) {
validators.push(validator);
} else {
reportCannotResolvedPath(schemaData.schema, context);
}
} else {
const validator = schemaObjectToValidator(schemaData.schema, context);
if (validator) {
validators.push(validator);
} else {
reportCannotResolvedObject(context);
}
}
}
return validators.length ? validators : null;
}

/** Create combined validator */
function createValidator(context: RuleContext, filename: string) {
const mergeSchemas = parseMergeSchemasOption(
context.options[0]?.mergeSchemas,
);

const validators: Validator[] = [];
if (mergeSchemas) {
if (mergeSchemas.includes("$schema")) {
validators.push(...(get$SchemaValidators(context) || []));
}
if (mergeSchemas.includes("options")) {
validators.push(...(getOptionsValidators(context, filename) || []));
}
if (mergeSchemas.includes("catalog")) {
validators.push(...(getCatalogValidators(context, filename) || []));
}
} else {
validators.push(
...(get$SchemaValidators(context) ||
getOptionsValidators(context, filename) ||
getCatalogValidators(context, filename) ||
[]),
);
}

return (data: unknown) =>
validators.reduce(
(errors, validator) => [...errors, ...validator(data)],
[] as ValidateError[],
);
}
},
});

Expand Down
61 changes: 61 additions & 0 deletions tests/src/rules/no-invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,67 @@ tester.run(
'"extends[0]" must be string.',
],
},
{
filename: path.join(__dirname, ".eslintrc.json"),
code: '{ "extends": [ 42 ], "$schema": "https://json.schemastore.org/eslintrc" }',
options: [
{
schemas: [
{
fileMatch: ["tests/src/rules/.eslintrc.json"],
schema: {
properties: {
foo: {
type: "number",
},
},
required: ["foo"],
},
},
],
mergeSchemas: true,
useSchemastoreCatalog: false,
},
],
errors: [
"Root must have required property 'foo'.",
'"extends" must be string.',
'"extends" must match exactly one schema in oneOf.',
'"extends[0]" must be string.',
],
},
{
filename: path.join(__dirname, "version.json"),
code: '{ "extends": [ 42 ], "$schema": "https://json.schemastore.org/eslintrc" }',
options: [
{
schemas: [
{
fileMatch: ["tests/src/rules/version.json"],
schema: {
properties: {
foo: {
type: "number",
},
},
required: ["foo"],
},
},
],
mergeSchemas: true,
useSchemastoreCatalog: true,
},
],
errors: [
"Root must have required property 'foo'.",
"Root must have required property 'version'.",
"Root must have required property 'inherit'.",
"Root must match a schema in anyOf.",
'"extends" must be string.',
'"extends" must match exactly one schema in oneOf.',
'"extends[0]" must be string.',
],
},
{
filename: path.join(__dirname, ".prettierrc.toml"),
code: `
Expand Down

0 comments on commit c5ddb60

Please sign in to comment.