Commit c9fe45fe authored by David Sveningsson's avatar David Sveningsson

feat(config): configuration schema validation

fixes #61
parent bcab9e41
Pipeline #103120565 passed with stages
in 9 minutes and 11 seconds
......@@ -65,7 +65,7 @@ module.exports = new Package("html-validate-docs", [
];
copySchema.outputFolder = "public/schemas";
copySchema.files = ["elements/elements.json"];
copySchema.files = ["src/schema/elements.json", "src/schema/config.json"];
writeFilesProcessor.outputFolder = "public";
})
......
......@@ -10,7 +10,7 @@ module.exports = function copySchemaProcessor(copySchema, readFilesProcessor) {
function $process() {
const root = readFilesProcessor.basePath;
const outputFolder = path.join(root, copySchema.outputFolder, "schemas");
const outputFolder = path.join(root, copySchema.outputFolder);
mkdirp.sync(outputFolder);
......
......@@ -103,8 +103,12 @@ function renameStdin(report: Report, filename: string): void {
}
function handleValidationError(err: SchemaValidationError): void {
const filename = path.relative(process.cwd(), err.filename);
console.log(chalk.red(`A configuration error was found in "${filename}":`));
if (err.filename) {
const filename = path.relative(process.cwd(), err.filename);
console.log(chalk.red(`A configuration error was found in "${filename}":`));
} else {
console.log(chalk.red(`A configuration error was found:`));
}
if (console.group) console.group();
{
console.log(err.prettyError());
......
......@@ -10,6 +10,19 @@ declare module "./config-data" {
}
}
jest.mock("ajv", () => {
class MockAjv {
public compile(): () => boolean {
/* always valid */
return () => true;
}
public addMetaSchema(): void {
/* do nothing */
}
}
return MockAjv;
});
class MockConfig {
public static empty(): Config {
return Config.empty();
......
import fs from "fs";
import path from "path";
import { Source } from "../context";
import { SchemaValidationError } from "../error";
import { UserError } from "../error/user-error";
import { Transformer, TRANSFORMER_API } from "../transform";
import { Config } from "./config";
......@@ -80,6 +81,17 @@ describe("config", () => {
);
});
it("should throw error if file is invalid", () => {
expect(() =>
Config.fromObject({
rules: "spam",
} as any)
).toThrow("Invalid configuration: /rules: type should be object");
expect(() => Config.fromFile("invalid-file.json")).toThrow(
'Failed to read configuration from "invalid-file.json"'
);
});
describe("merge()", () => {
it("should merge two configs", () => {
const a = Config.fromObject({ rules: { foo: 1 } });
......@@ -99,7 +111,7 @@ describe("config", () => {
describe("getRules()", () => {
it("should handle when config is missing rules", () => {
const config = Config.fromObject({ rules: null });
const config = Config.fromObject({ rules: undefined });
expect(config.get().rules).toEqual({});
expect(config.getRules()).toEqual(new Map());
});
......@@ -142,16 +154,6 @@ describe("config", () => {
]);
});
it("should throw on invalid severity", () => {
const fn = Config.fromObject as (options: any) => Config;
const config = fn({
rules: {
bar: "foo",
},
});
expect(() => config.getRules()).toThrow('Invalid severity "foo"');
});
it("should retain options", () => {
const config = Config.fromObject({
rules: {
......@@ -166,11 +168,6 @@ describe("config", () => {
["baz", [Severity.WARN, {}]],
]);
});
it("should handle when rules are unset", () => {
const config = Config.fromObject({ rules: null });
expect(Array.from(config.getRules().entries())).toEqual([]);
});
});
describe("fromFile()", () => {
......@@ -728,8 +725,8 @@ describe("config", () => {
it("should handle unset fields", () => {
const config = Config.fromObject({
plugins: null,
transform: null,
plugins: undefined,
transform: undefined,
});
expect(() => {
config.init();
......@@ -759,4 +756,48 @@ describe("config", () => {
expect(config.findRootDir()).toEqual(process.cwd());
spy.mockRestore();
});
describe("schema validation", () => {
describe("valid", () => {
it.each([
["empty", {}],
["root true", { root: true }],
["root false", { root: false }],
["extends empty", { extends: [] }],
["extends string", { extends: ["foo", "bar", "baz"] }],
["elements empty", { elements: [] }],
["elements string", { elements: ["foo", "bar", "baz"] }],
["plugins empty", { plugins: [] }],
["plugins string", { plugins: ["foo", "bar", "baz"] }],
["transform empty", { transform: {} }],
["transform patterns", { transform: { "^foo$": "bar" } }],
["rules empty", { rules: {} }],
["rules with numeric severity", { rules: { foo: 0, bar: 1, baz: 2 } }],
["rules with severity", { rules: { a: "off", b: "warn", c: "error" } }],
["rules with missing options", { rules: { foo: ["error"] } }],
["rules with options", { rules: { foo: ["error", { spam: "ham" }] } }],
] as Array<[string, any]>)("%s", (_, config: any) => {
expect(() => Config.validate(config)).not.toThrow();
});
});
describe("invalid", () => {
it.each([
["root garbage", { root: "asdf" }],
["extends garbage", { extends: "asdf" }],
["extends invalid", { extends: [1] }],
["elements garbage", { elements: "asdf" }],
["elements invalid", { elements: [1] }],
["plugins garbage", { plugins: "asdf" }],
["plugins invalid", { plugins: [1] }],
["transform garbage", { transform: "asdf" }],
["transform invalid", { transform: { foo: 1 } }],
["rules with invalid numeric severity", { rules: { foo: -1, bar: 3 } }],
["rules with invalid severity", { rules: { foo: "spam" } }],
["additional property", { foo: "bar" }],
] as Array<[string, any]>)("%s", (_, config: any) => {
expect(() => Config.validate(config)).toThrow(SchemaValidationError);
});
});
});
});
import Ajv from "ajv";
import deepmerge from "deepmerge";
import fs from "fs";
import path from "path";
import { Source } from "../context";
import { NestedError } from "../error";
import { NestedError, SchemaValidationError } from "../error";
import { MetaTable } from "../meta";
import { MetaDataTable } from "../meta/element";
import { Plugin } from "../plugin";
import schema from "../schema/config.json";
import { TransformContext, Transformer, TRANSFORMER_API } from "../transform";
import { ConfigData, TransformMap } from "./config-data";
import defaultConfig from "./default";
......@@ -30,6 +32,10 @@ const recommended = require("./recommended");
const document = require("./document");
let rootDirCache: string = null;
const ajv = new Ajv({ jsonPointers: true });
ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));
const validator = ajv.compile(schema);
function overwriteMerge<T>(a: T[], b: T[]): T[] {
return b;
}
......@@ -106,7 +112,11 @@ export class Config {
/**
* Create configuration from object.
*/
public static fromObject(options: ConfigData): Config {
public static fromObject(
options: ConfigData,
filename: string | null = null
): Config {
Config.validate(options, filename);
return new Config(options);
}
......@@ -121,7 +131,25 @@ export class Config {
*/
public static fromFile(filename: string): Config {
const configdata = loadFromFile(filename);
return new Config(configdata);
return Config.fromObject(configdata, filename);
}
/**
* Validate configuration data.
*
* Throws SchemaValidationError if invalid.
*/
public static validate(options: ConfigData, filename?: string): void {
const valid = validator(options);
if (!valid) {
throw new SchemaValidationError(
filename,
`Invalid configuration`,
options,
schema,
validator.errors
);
}
}
/**
......
......@@ -13,6 +13,9 @@ jest.mock("ajv", () => {
public compile(): () => boolean {
return validate;
}
public addMetaSchema(): void {
/* do nothing */
}
}
return MockAjv;
});
......
......@@ -4,6 +4,7 @@ import jsonMergePatch from "json-merge-patch";
import { HtmlElement } from "../dom";
import { SchemaValidationError, UserError } from "../error";
import { SchemaValidationPatch } from "../plugin";
import schema from "../schema/elements.json";
import {
ElementTable,
MetaData,
......@@ -41,7 +42,7 @@ export class MetaTable {
public constructor() {
this.elements = {};
this.schema = clone(require("../../elements/elements.json"));
this.schema = clone(schema);
}
public init(): void {
......
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$id": "https://html-validate.org/schemas/config.json",
"version": "1.0",
"type": "object",
"additionalProperties": false,
"properties": {
"root": {
"type": "boolean",
"title": "Mark as root configuration",
"description": "If this is set to true no further configurations will be searched.",
"default": false
},
"extends": {
"type": "array",
"items": {
"type": "string"
},
"title": "Configurations to extend",
"description": "Array of sharable or builtin configurations to extend."
},
"elements": {
"type": "array",
"items": {
"anyOf": [{ "type": "string" }, { "type": "object" }]
},
"title": "Element metadata to load",
"description": "Array of modules, plugins or files to load element metadata from. Use <rootDir> to refer to the folder with the package.json file.",
"example": [
"html-validate:recommended",
"plugin:recommended",
"module",
"./local-file.json"
]
},
"plugins": {
"type": "array",
"items": {
"type": "string"
},
"title": "Plugins to load",
"description": "Array of plugins load. Use <rootDir> to refer to the folder with the package.json file.",
"example": ["my-plugin", "./local-plugin"]
},
"transform": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "File transformations to use.",
"description": "Object where key is regular expression to match filename and value is name of transformer.",
"example": {
"^.*\\.foo$": "my-transformer",
"^.*\\.bar$": "my-plugin",
"^.*\\.baz$": "my-plugin:named"
}
},
"rules": {
"type": "object",
"patternProperties": {
".*": {
"anyOf": [
{ "enum": [0, 1, 2, "off", "warn", "error"] },
{
"type": "array",
"items": [{ "enum": [0, 1, 2, "off", "warn", "error"] }]
}
]
}
},
"title": "Rule configuration.",
"description": "Enable/disable rules, set severity. Some rules have additional configuration like style or patterns to use.",
"example": {
"foo": "error",
"bar": "off",
"baz": ["error", { "style": "camelcase" }]
}
}
}
}
......@@ -8,6 +8,7 @@
"module": "commonjs",
"noImplicitAny": true,
"outDir": "build",
"resolveJsonModule": true,
"sourceMap": false,
"target": "es2017",
"strict": true,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment