Commit 5ab6a21b authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): validate rule configuration

parent fff87f14
......@@ -148,6 +148,50 @@ class MyRule extends Rule<void, RuleOptions> {
}
```
### Options validation
If the optional `schema()` function is implemented is should return [JSON schema](https://json-schema.org/learn/getting-started-step-by-step.html) for the options interface.
<div class="alert alert-info">
<i class="fa fa-info-circle" aria-hidden="true"></i>
<strong>Note</strong>
<p>Note the function must be <code>static</code> as it will be called before the instance is created, i.e. no unvalidated options will ever touch the rule implementation.</p>
</div>
The object is merged into the `properties` object of a boilerplate object schema.
```typescript
class MyRule extends Rule<void, RuleOptions> {
public static schema(): SchemaObject {
return {
text: {
type: "string",
},
};
}
}
```
Given the above schema users will receive errors such as following:
```plaintext
A configuration error was found in ".htmlvalidate.json":
TYPE should be string
3 | "elements": ["html5"],
4 | "rules": {
> 5 | "my-rule": ["error", {"text": 12 }]
| ^^ 👈🏽 type should be string
6 | }
7 | }
8 |
```
Schema validation will help both the user and the rule author:
- The user will get a descriptive errors message including details of which configuration file and where the error occured.
- The rule author will not have to worry about the data the `options` parameter, i.e. it can safely be assumed each property has the proper datatypes and other restrictions imposed by the schema.
## Cache
Expensive operations on `DOMNode` can be cached using the {@link dev/cache cache API}.
......
......@@ -16,6 +16,9 @@ jest.mock("ajv", () => {
/* always valid */
return () => true;
}
public getSchema(): undefined {
return undefined;
}
public addMetaSchema(): void {
/* do nothing */
}
......
......@@ -10,7 +10,9 @@ import { Plugin } from "../plugin";
import schema from "../schema/config.json";
import { TransformContext, Transformer, TRANSFORMER_API } from "../transform";
import { requireUncached } from "../utils";
import { ConfigData, RuleOptions, TransformMap } from "./config-data";
import bundledRules from "../rules";
import { Rule } from "../rule";
import { ConfigData, RuleConfig, RuleOptions, TransformMap } from "./config-data";
import defaultConfig from "./default";
import { ConfigError } from "./error";
import { parseSeverity, Severity } from "./severity";
......@@ -136,17 +138,26 @@ export class Config {
*
* Throws SchemaValidationError if invalid.
*/
public static validate(options: ConfigData, filename: string | null = null): void {
const valid = validator(options);
public static validate(configData: ConfigData, filename: string | null = null): void {
const valid = validator(configData);
if (!valid) {
throw new SchemaValidationError(
filename,
`Invalid configuration`,
options,
configData,
schema,
validator.errors ?? []
);
}
if (configData.rules) {
const normalizedRules = Config.getRulesObject(configData.rules);
for (const [ruleId, [, ruleOptions]] of normalizedRules.entries()) {
const cls = bundledRules[ruleId];
const path = `/rules/${ruleId}/1`;
Rule.validateOptions(cls, ruleId, path, ruleOptions, filename, configData);
}
}
}
/**
......@@ -319,8 +330,12 @@ export class Config {
* Get all configured rules, their severity and options.
*/
public getRules(): Map<string, [Severity, RuleOptions]> {
return Config.getRulesObject(this.config.rules ?? {});
}
private static getRulesObject(src: RuleConfig): Map<string, [Severity, RuleOptions]> {
const rules = new Map<string, [Severity, RuleOptions]>();
for (const [ruleId, data] of Object.entries(this.config.rules ?? {})) {
for (const [ruleId, data] of Object.entries(src)) {
let options = data;
if (!Array.isArray(options)) {
options = [options, {}];
......
import path from "path";
import { Config, Severity } from "./config";
import { Config, ConfigData, Severity } from "./config";
import { Location } from "./context";
import { HtmlElement, NodeClosed } from "./dom";
import { Event, EventCallback, TagEndEvent, TagStartEvent } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { Rule, ruleDocumentationUrl, IncludeExcludeOptions } from "./rule";
import { Rule, ruleDocumentationUrl, IncludeExcludeOptions, SchemaObject } from "./rule";
import { MetaTable } from "./meta";
interface RuleContext {
......@@ -373,6 +373,108 @@ it("should be off by default", () => {
expect(rule.getSeverity()).toEqual(Severity.DISABLED);
});
describe("validateOptions()", () => {
class MockRuleSchema extends Rule {
public static schema(): SchemaObject {
return {
foo: {
type: "number",
},
};
}
public setup(): void {
/* do nothing */
}
}
it("should throw validation error if options does not match schema", () => {
expect.assertions(1);
const options = { foo: "bar" };
const config: ConfigData = {
rules: {
"mock-rule-invalid": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-invalid/1";
expect(() => {
return Rule.validateOptions(
MockRuleSchema,
"mock-rule-invalid",
jsonPath,
options,
"inline",
config
);
}).toThrowErrorMatchingInlineSnapshot(
`"Rule configuration error: /rules/mock-rule-invalid/1/foo: type should be number"`
);
});
it("should not throw validation error if options matches schema", () => {
expect.assertions(1);
const options = { foo: 12 };
const config: ConfigData = {
rules: {
"mock-rule-valid": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-valid/1";
expect(() => {
return Rule.validateOptions(
MockRuleSchema,
"mock-rule-valid",
jsonPath,
options,
"inline",
config
);
}).not.toThrow();
});
it("should handle rules without schema", () => {
expect.assertions(1);
const options = { foo: "spam" };
const config: ConfigData = {
rules: {
"mock-rule-no-schema": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-no-schema/1";
expect(() => {
return Rule.validateOptions(
MockRule,
"mock-rule-no-schema",
jsonPath,
options,
"inline",
config
);
}).not.toThrow();
});
it("should handle missing class", () => {
expect.assertions(1);
const options = { foo: "spam" };
const config: ConfigData = {
rules: {
"mock-rule-undefined": ["error", options],
},
};
const jsonPath = "/rules/mock-rule-undefined/1";
expect(() => {
return Rule.validateOptions(
undefined,
"mock-rule-undefined",
jsonPath,
options,
"inline",
config
);
}).not.toThrow();
});
});
it("ruleDocumentationUrl() should return URL to rule documentation", () => {
expect.assertions(1);
expect(ruleDocumentationUrl("src/rules/foo.ts")).toEqual(
......
import path from "path";
import { Severity } from "./config";
import Ajv, { ErrorObject, SchemaObject, ValidateFunction } from "ajv";
import { ConfigData, Severity } from "./config";
import { Location } from "./context";
import { DOMNode } from "./dom";
import { Event, ListenEventMap } from "./event";
import { Parser } from "./parser";
import { Reporter } from "./reporter";
import { MetaTable, MetaLookupableProperty } from "./meta";
import { SchemaValidationError } from "./error";
export { SchemaObject } from "ajv";
const homepage = require("../package.json").homepage;
......@@ -14,6 +18,9 @@ const remapEvents: Record<string, string> = {
"tag:close": "tag:end",
};
const ajv = new Ajv({ strict: true, strictTuples: true, strictTypes: true });
ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json"));
export interface RuleDocumentation {
description: string;
url?: string;
......@@ -21,6 +28,7 @@ export interface RuleDocumentation {
export interface RuleConstructor<T, U> {
new (options?: any): Rule<T, U>;
schema(): SchemaObject | null | undefined;
}
export interface IncludeExcludeOptions {
......@@ -28,6 +36,30 @@ export interface IncludeExcludeOptions {
exclude: string[] | null;
}
/**
* Get (cached) schema validator for rule options.
*
* @param ruleId - Rule ID used as key for schema lookups.
* @param properties - Uncompiled schema.
*/
function getSchemaValidator(ruleId: string, properties: SchemaObject): ValidateFunction {
const $id = `rule/${ruleId}`;
const cached = ajv.getSchema($id);
if (cached) {
return cached;
}
const schema = {
$id,
type: "object",
additionalProperties: false,
properties,
};
return ajv.compile(schema);
}
export abstract class Rule<ContextType = void, OptionsType = void> {
private reporter: Reporter;
private parser: Parser;
......@@ -148,6 +180,17 @@ export abstract class Rule<ContextType = void, OptionsType = void> {
return this.meta.getTagsDerivedFrom(tagName);
}
/**
* JSON schema for rule options.
*
* Rules should override this to return an object with JSON schema to validate
* rule options. If `null` or `undefined` is returned no validation is
* performed.
*/
public static schema(): SchemaObject | null | undefined {
return null;
}
/**
* Report a new error.
*
......@@ -242,6 +285,52 @@ export abstract class Rule<ContextType = void, OptionsType = void> {
this.meta = meta;
}
/**
* Validate rule options against schema. Throws error if object does not validate.
*
* For rules without schema this function does nothing.
*
* @throws {@link SchemaValidationError}
* Thrown when provided options does not validate against rule schema.
*
* @param cls - Rule class (constructor)
* @param ruleId - Rule identifier
* @param jsonPath - JSON path from which [[options]] can be found in [[config]]
* @param options - User configured options to be validated
* @param filename - Filename from which options originated
* @param config - Configuration from which options originated
*
* @hidden
*/
public static validateOptions(
cls: RuleConstructor<unknown, unknown> | undefined,
ruleId: string,
jsonPath: string,
options: unknown,
filename: string | null,
config: ConfigData
): void {
if (!cls) {
return;
}
const schema = cls.schema();
if (!schema) {
return;
}
const isValid = getSchemaValidator(ruleId, schema);
if (!isValid(options)) {
/* istanbul ignore next: it is always set when validation fails */
const errors = isValid.errors ?? [];
const mapped = errors.map((error: ErrorObject) => {
error.dataPath = `${jsonPath}${error.dataPath}`;
return error;
});
throw new SchemaValidationError(filename, `Rule configuration error`, config, schema, mapped);
}
}
/**
* Rule setup callback.
*
......
Supports Markdown
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