From aeb7d5b842741d85229fb2c8575ae2c58c8dfbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Thu, 14 Apr 2022 14:47:54 +0200 Subject: [PATCH] feat(core): support JSON ruleset validation (#2062) --- package.json | 4 +- packages/core/package.json | 15 +- .../__fixtures__/foo-extends-bar-ruleset.json | 12 -- .../__tests__/__fixtures__/foo-ruleset.json | 14 -- .../__fixtures__/invalid-ruleset.json | 22 --- .../__fixtures__/ruleset-with-merge-keys.yaml | 20 --- .../ruleset-with-missing-functions.json | 4 - .../__fixtures__/valid-flat-ruleset-2.json | 10 -- .../__fixtures__/valid-flat-ruleset.json | 10 -- .../src/ruleset/__tests__/ruleset.test.ts | 2 +- packages/core/src/ruleset/index.ts | 2 +- packages/core/src/ruleset/mergers/rules.ts | 2 +- .../core/src/ruleset/meta/js-extensions.json | 64 ++++++++ .../src/ruleset/meta/json-extensions.json | 79 ++++++++++ .../src/{ => ruleset}/meta/rule.schema.json | 4 +- .../{ => ruleset}/meta/ruleset.schema.json | 43 ++--- .../core/src/{ => ruleset}/meta/shared.json | 7 +- packages/core/src/ruleset/validation.ts | 129 --------------- .../__tests__/__fixtures__/invalid-ruleset.ts | 24 +++ .../__fixtures__/valid-flat-ruleset.ts | 12 ++ .../__tests__/validation.test.ts | 149 +++++++++++++++--- packages/core/src/ruleset/validation/ajv.ts | 66 ++++++++ .../core/src/ruleset/validation/assertions.ts | 33 ++++ .../core/src/ruleset/validation/errors.ts | 65 ++++++++ packages/core/src/ruleset/validation/index.ts | 2 + 25 files changed, 501 insertions(+), 293 deletions(-) delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/foo-extends-bar-ruleset.json delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/foo-ruleset.json delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/invalid-ruleset.json delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-merge-keys.yaml delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-missing-functions.json delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset-2.json delete mode 100644 packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset.json create mode 100644 packages/core/src/ruleset/meta/js-extensions.json create mode 100644 packages/core/src/ruleset/meta/json-extensions.json rename packages/core/src/{ => ruleset}/meta/rule.schema.json (94%) rename packages/core/src/{ => ruleset}/meta/ruleset.schema.json (85%) rename packages/core/src/{ => ruleset}/meta/shared.json (89%) delete mode 100644 packages/core/src/ruleset/validation.ts create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/invalid-ruleset.ts create mode 100644 packages/core/src/ruleset/validation/__tests__/__fixtures__/valid-flat-ruleset.ts rename packages/core/src/ruleset/{ => validation}/__tests__/validation.test.ts (85%) create mode 100644 packages/core/src/ruleset/validation/ajv.ts create mode 100644 packages/core/src/ruleset/validation/assertions.ts create mode 100644 packages/core/src/ruleset/validation/errors.ts create mode 100644 packages/core/src/ruleset/validation/index.ts diff --git a/package.json b/package.json index 601bd616e..4f92b8ada 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "lint": "yarn lint.prettier && yarn lint.eslint", "lint.fix": "yarn lint.prettier --write && yarn lint.eslint --fix", "lint.eslint": "eslint --cache --cache-location .cache/.eslintcache --ext=.js,.mjs,.ts packages test-harness", - "lint.prettier": "prettier --ignore-path .eslintignore --ignore-unknown --check packages/core/src/meta/*.json packages/rulesets/src/{asyncapi,oas}/schemas/*.json docs/**/*.md README.md", + "lint.prettier": "prettier --ignore-path .eslintignore --ignore-unknown --check packages/core/src/ruleset/meta/*.json packages/rulesets/src/{asyncapi,oas}/schemas/*.json docs/**/*.md README.md", "pretest": "yarn workspace @stoplight/spectral-ruleset-migrator pretest", "test": "yarn pretest && yarn test.karma && yarn test.jest", "test.harness": "jest -c ./test-harness/jest.config.js", @@ -116,7 +116,7 @@ "README.md": [ "prettier --write" ], - "packages/core/src/meta/*.json": [ + "packages/core/src/ruleset/meta/*.json": [ "prettier --ignore-path .eslintignore --write" ], "packages/rulesets/src/{asyncapi,oas}/schemas/*.json": [ diff --git a/packages/core/package.json b/packages/core/package.json index e4aa4e110..da36979a3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,8 +1,6 @@ { "name": "@stoplight/spectral-core", "version": "1.11.1", - "main": "dist/index.js", - "types": "dist/index.d.ts", "sideEffects": false, "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", @@ -15,6 +13,19 @@ "files": [ "dist" ], + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js" + }, + "./ruleset/validation": { + "types": "./dist/ruleset/validation/index.d.ts", + "require": "./dist/ruleset/validation/index.js" + } + }, "engines": { "node": "^12.20 || >= 14.13" }, diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/foo-extends-bar-ruleset.json b/packages/core/src/ruleset/__tests__/__fixtures__/foo-extends-bar-ruleset.json deleted file mode 100644 index 0f25ca5a2..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/foo-extends-bar-ruleset.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": ["./bar-extends-foo-ruleset.json"], - "rules": { - "foo-rule": { - "message": "Foo is falsy", - "given": "$.foo", - "then": { - "function": "falsy" - } - } - } -} diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/foo-ruleset.json b/packages/core/src/ruleset/__tests__/__fixtures__/foo-ruleset.json deleted file mode 100644 index f483ef52d..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/foo-ruleset.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "functions": [ - "foo.cjs" - ], - "rules": { - "foo-rule": { - "message": "should be OK", - "given": "$.info", - "then": { - "function": "foo.cjs" - } - } - } -} diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/invalid-ruleset.json b/packages/core/src/ruleset/__tests__/__fixtures__/invalid-ruleset.json deleted file mode 100644 index c885556db..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/invalid-ruleset.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "rules": { - "no-given-no-then": { - "message": "deliberetely invalid" - }, - "valid-rule": { - "message": "should be OK", - "given": "$.info", - "then": { - "function": "truthy" - } - }, - "rule-with-invalid-enum": { - "given": "$.info", - "then": { - "function": "truthy" - }, - "severity": "must not be a string", - "type": "some bs type value" - } - } -} diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-merge-keys.yaml b/packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-merge-keys.yaml deleted file mode 100644 index f10f02f51..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-merge-keys.yaml +++ /dev/null @@ -1,20 +0,0 @@ -rules: - no-x-headers-request: &no-x-headers - description: "All 'HTTP' headers SHOULD NOT include 'X-' headers (https://tools.ietf.org/html/rfc6648)." - severity: warn - given: - - "$..parameters[?(@.in == 'header')].name" - message: |- - HTTP header '{{value}}' SHOULD NOT include 'X-' prefix in {{path}} - recommended: true - type: style - then: - function: pattern - functionOptions: - notMatch: "/^[xX]-/" - no-x-headers-response: - <<: *no-x-headers - given: - - $.[responses][*].headers.*~ - message: |- - HTTP header '{{value}}' SHOULD NOT include 'X-' prefix in {{path}} diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-missing-functions.json b/packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-missing-functions.json deleted file mode 100644 index 3573d2b49..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/ruleset-with-missing-functions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "rules": {}, - "functions": ["boo"] -} diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset-2.json b/packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset-2.json deleted file mode 100644 index 73b58dfbf..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset-2.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "rules": { - "valid-rule-2": { - "given": "$.info", - "then": { - "function": "truthy" - } - } - } -} diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset.json b/packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset.json deleted file mode 100644 index 21b4936bc..000000000 --- a/packages/core/src/ruleset/__tests__/__fixtures__/valid-flat-ruleset.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "rules": { - "valid-rule": { - "given": "$.info", - "then": { - "function": "truthy" - } - } - } -} diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts index 4353ce3e0..bd23bd79f 100644 --- a/packages/core/src/ruleset/__tests__/ruleset.test.ts +++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts @@ -6,7 +6,7 @@ import { DiagnosticSeverity } from '@stoplight/types'; import { Ruleset } from '../ruleset'; import { RulesetDefinition } from '../types'; import { print } from './__helpers__/print'; -import { RulesetValidationError } from '../validation'; +import { RulesetValidationError } from '../validation/index'; import { isPlainObject } from '@stoplight/json'; import { Format } from '../format'; import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema'; diff --git a/packages/core/src/ruleset/index.ts b/packages/core/src/ruleset/index.ts index e3ac2fb6e..6a91d8fa9 100644 --- a/packages/core/src/ruleset/index.ts +++ b/packages/core/src/ruleset/index.ts @@ -1,4 +1,4 @@ -export { assertValidRuleset, RulesetValidationError } from './validation'; +export { assertValidRuleset, RulesetValidationError } from './validation/index'; export { getDiagnosticSeverity } from './utils'; export { createRulesetFunction, SchemaDefinition as RulesetFunctionSchemaDefinition } from './rulesetFunction'; export { Format } from './format'; diff --git a/packages/core/src/ruleset/mergers/rules.ts b/packages/core/src/ruleset/mergers/rules.ts index 04396b5b0..fd6747737 100644 --- a/packages/core/src/ruleset/mergers/rules.ts +++ b/packages/core/src/ruleset/mergers/rules.ts @@ -1,5 +1,5 @@ import { Optional } from '@stoplight/types'; -import { assertValidRule } from '../validation'; +import { assertValidRule } from '../validation/assertions'; import { Rule } from '../rule'; import type { Ruleset } from '../ruleset'; import { FileRuleDefinition } from '../types'; diff --git a/packages/core/src/ruleset/meta/js-extensions.json b/packages/core/src/ruleset/meta/js-extensions.json new file mode 100644 index 000000000..1d497c334 --- /dev/null +++ b/packages/core/src/ruleset/meta/js-extensions.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "@stoplight/spectral-core/meta/extensions", + "$defs": { + "Extends": { + "$anchor": "extends", + "oneOf": [ + { + "$id": "ruleset", + "$ref": "ruleset.schema#", + "errorMessage": "must be a valid ruleset" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "ruleset" + }, + { + "type": "array", + "minItems": 2, + "additionalItems": false, + "items": [ + { + "$ref": "ruleset" + }, + { + "type": "string", + "enum": ["off", "recommended", "all"], + "errorMessage": "allowed types are \"off\", \"recommended\" and \"all\"" + } + ] + } + ] + } + } + ], + "errorMessage": "must be a valid ruleset" + }, + "Format": { + "$anchor": "format", + "spectral-runtime": "format", + "errorMessage": "must be a valid format" + }, + "Function": { + "$anchor": "function", + "spectral-runtime": "function", + "type": "object", + "properties": { + "function": true + }, + "required": ["function"] + }, + "Functions": { + "$anchor": "functions", + "not": {} + }, + "FunctionsDir": { + "$anchor": "functionsDir", + "not": {} + } + } +} diff --git a/packages/core/src/ruleset/meta/json-extensions.json b/packages/core/src/ruleset/meta/json-extensions.json new file mode 100644 index 000000000..b57698d83 --- /dev/null +++ b/packages/core/src/ruleset/meta/json-extensions.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "@stoplight/spectral-core/meta/extensions", + "$defs": { + "Extends": { + "$anchor": "extends", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 2, + "additionalItems": false, + "items": [ + { + "type": "string" + }, + { + "enum": ["all", "recommended", "off"], + "errorMessage": "allowed types are \"off\", \"recommended\" and \"all\"" + } + ] + } + ] + } + } + ] + }, + "Format": { + "$anchor": "format", + "enum": [ + "oas2", + "oas3", + "oas3.0", + "oas3.1", + "asyncapi2", + "json-schema", + "json-schema-loose", + "json-schema-draft4", + "json-schema-draft6", + "json-schema-draft7", + "json-schema-draft-2019-09", + "json-schema-2019-09", + "json-schema-draft-2020-12", + "json-schema-2020-12" + ], + "errorMessage": "must be a valid format" + }, + "Functions": { + "$anchor": "functions", + "type": "array", + "items": { + "type": "string" + } + }, + "FunctionsDir": { + "$anchor": "functionsDir", + "type": "string" + }, + "Function": { + "$anchor": "function", + "type": "object", + "properties": { + "function": { + "type": "string" + } + }, + "required": ["function"] + } + } +} diff --git a/packages/core/src/meta/rule.schema.json b/packages/core/src/ruleset/meta/rule.schema.json similarity index 94% rename from packages/core/src/meta/rule.schema.json rename to packages/core/src/ruleset/meta/rule.schema.json index d49fb758b..4372cacff 100644 --- a/packages/core/src/meta/rule.schema.json +++ b/packages/core/src/ruleset/meta/rule.schema.json @@ -13,9 +13,7 @@ } }, { - "type": "object", - "spectral-runtime": "ruleset-function", - "required": ["function"] + "$ref": "extensions#function" } ] }, diff --git a/packages/core/src/meta/ruleset.schema.json b/packages/core/src/ruleset/meta/ruleset.schema.json similarity index 85% rename from packages/core/src/meta/ruleset.schema.json rename to packages/core/src/ruleset/meta/ruleset.schema.json index d567e704d..553aa01fc 100644 --- a/packages/core/src/meta/ruleset.schema.json +++ b/packages/core/src/ruleset/meta/ruleset.schema.json @@ -9,49 +9,26 @@ "format": "url", "errorMessage": "must be a valid URL" }, - "description": { "type": "string" }, + "description": { + "type": "string" + }, "rules": { "type": "object", "additionalProperties": { "$ref": "rule.schema#" } }, + "functions": { + "$ref": "extensions#functions" + }, + "functionsDir": { + "$ref": "extensions#functionsDir" + }, "formats": { "$ref": "shared#formats" }, "extends": { - "oneOf": [ - { - "$ref": "#", - "errorMessage": "must be a valid ruleset" - }, - { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/properties/extends/oneOf/0" - }, - { - "type": "array", - "minItems": 2, - "additionalItems": false, - "items": [ - { - "$ref": "#" - }, - { - "type": "string", - "enum": ["off", "recommended", "all"], - "errorMessage": "allowed types are \"off\", \"recommended\" and \"all\"" - } - ] - } - ] - } - } - ], - "errorMessage": "must be a valid ruleset" + "$ref": "extensions#extends" }, "parserOptions": { "type": "object", diff --git a/packages/core/src/meta/shared.json b/packages/core/src/ruleset/meta/shared.json similarity index 89% rename from packages/core/src/meta/shared.json rename to packages/core/src/ruleset/meta/shared.json index 7ae2f4496..e46e507cf 100644 --- a/packages/core/src/meta/shared.json +++ b/packages/core/src/ruleset/meta/shared.json @@ -6,15 +6,10 @@ "$anchor": "formats", "type": "array", "items": { - "$ref": "#format" + "$ref": "extensions#format" }, "errorMessage": "must be an array of formats" }, - "Format": { - "$anchor": "format", - "spectral-runtime": "spectral-format", - "errorMessage": "must be a valid format" - }, "DiagnosticSeverity": { "enum": [-1, 0, 1, 2, 3] }, diff --git a/packages/core/src/ruleset/validation.ts b/packages/core/src/ruleset/validation.ts deleted file mode 100644 index 8df74f589..000000000 --- a/packages/core/src/ruleset/validation.ts +++ /dev/null @@ -1,129 +0,0 @@ -import Ajv, { _, ErrorObject } from 'ajv'; -import addFormats from 'ajv-formats'; -import addErrors from 'ajv-errors'; -import { isPlainObject } from '@stoplight/json'; -import { printPath, PrintStyle } from '@stoplight/spectral-runtime'; - -import * as ruleSchema from '../meta/rule.schema.json'; -import * as rulesetSchema from '../meta/ruleset.schema.json'; -import * as shared from '../meta/shared.json'; -import type { FileRuleDefinition, RuleDefinition, RulesetDefinition } from './types'; - -const message = _`'spectral-message'`; - -const ajv = new Ajv({ allErrors: true, strict: true, strictRequired: false, keywords: ['$anchor', 'x-internal'] }); -addFormats(ajv); -addErrors(ajv); -ajv.addKeyword({ - keyword: 'spectral-runtime', - schemaType: 'string', - error: { - message(ctx) { - return _`${ctx.data}[Symbol.for(${message})]`; - }, - }, - code(cxt) { - const { data } = cxt; - - switch (cxt.schema as unknown) { - case 'spectral-format': - cxt.fail(_`typeof ${data} !== "function"`); - break; - case 'spectral-function': - cxt.pass(_`typeof ${data}.function === "function"`); - cxt.pass( - _`(() => { try { ${data}.function.validator && ${data}.function.validator('functionOptions' in ${data} ? ${data} : null); } catch (e) { ${data}[${message}] = e.message } })()`, - ); - break; - } - }, -}); - -const validate = ajv.addSchema(ruleSchema).addSchema(shared).compile(rulesetSchema); - -export class RulesetValidationError extends Error { - constructor(public readonly message: string) { - super(message); - } -} - -const RULE_INSTANCE_PATH = /^\/rules\/[^/]+/; -const GENERIC_INSTANCE_PATH = /^\/(?:aliases|extends|overrides(?:\/\d+\/extends)?)/; - -class RulesetAjvValidationError extends RulesetValidationError { - constructor(public ruleset: Record, public errors: ErrorObject[]) { - super(RulesetAjvValidationError.serializeAjvErrors(ruleset, errors)); - } - - public static serializeAjvErrors(ruleset: Record, errors: ErrorObject[]): string { - const sortedErrors = [...errors] - .sort((errorA, errorB) => { - const diff = errorA.instancePath.length - errorB.instancePath.length; - return diff === 0 ? (errorA.keyword === 'errorMessage' && errorB.keyword !== 'errorMessage' ? -1 : 0) : diff; - }) - .filter((error, i, sortedErrors) => i === 0 || sortedErrors[i - 1].instancePath !== error.instancePath); - - const filteredErrors: ErrorObject[] = []; - - l: for (let i = 0; i < sortedErrors.length; i++) { - const error = sortedErrors[i]; - const prevError = i === 0 ? null : sortedErrors[i - 1]; - - if (GENERIC_INSTANCE_PATH.test(error.instancePath)) { - let x = 1; - while (i + x < sortedErrors.length) { - if ( - sortedErrors[i + x].instancePath.startsWith(error.instancePath) || - !GENERIC_INSTANCE_PATH.test(sortedErrors[i + x].instancePath) - ) { - continue l; - } - - x++; - } - } else if (prevError === null) { - filteredErrors.push(error); - continue; - } else { - const match = RULE_INSTANCE_PATH.exec(error.instancePath); - - if (match !== null && match[0] !== match.input && match[0] === prevError.instancePath) { - filteredErrors.pop(); - } - } - - filteredErrors.push(error); - } - - return filteredErrors - .map( - ({ message, instancePath }) => - `Error at ${printPath(instancePath.slice(1).split('/'), PrintStyle.Pointer)}: ${message ?? ''}`, - ) - .join('\n'); - } -} - -export function assertValidRuleset(ruleset: unknown): asserts ruleset is RulesetDefinition { - if (!isPlainObject(ruleset)) { - throw new Error('Provided ruleset is not an object'); - } - - if (!('rules' in ruleset) && !('extends' in ruleset) && !('overrides' in ruleset)) { - throw new RulesetValidationError('Ruleset must have rules or extends or overrides defined'); - } - - if (!validate(ruleset)) { - throw new RulesetAjvValidationError(ruleset, validate.errors ?? []); - } -} - -export function isValidRule(rule: FileRuleDefinition): rule is RuleDefinition { - return typeof rule === 'object' && rule !== null && !Array.isArray(rule) && ('given' in rule || 'then' in rule); -} - -export function assertValidRule(rule: FileRuleDefinition): asserts rule is RuleDefinition { - if (!isValidRule(rule)) { - throw new TypeError('Invalid rule'); - } -} diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/invalid-ruleset.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/invalid-ruleset.ts new file mode 100644 index 000000000..a041af340 --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/invalid-ruleset.ts @@ -0,0 +1,24 @@ +import { truthy } from '@stoplight/spectral-functions'; + +export default { + rules: { + 'no-given-no-then': { + message: 'deliberately invalid', + }, + 'valid-rule': { + message: 'should be OK', + given: '$.info', + then: { + function: truthy, + }, + }, + 'rule-with-invalid-enum': { + given: '$.info', + then: { + function: truthy, + }, + severity: 'must not be a string', + type: 'some bs type value', + }, + }, +}; diff --git a/packages/core/src/ruleset/validation/__tests__/__fixtures__/valid-flat-ruleset.ts b/packages/core/src/ruleset/validation/__tests__/__fixtures__/valid-flat-ruleset.ts new file mode 100644 index 000000000..94f809ceb --- /dev/null +++ b/packages/core/src/ruleset/validation/__tests__/__fixtures__/valid-flat-ruleset.ts @@ -0,0 +1,12 @@ +import { truthy } from '@stoplight/spectral-functions'; + +export default { + rules: { + 'valid-rule': { + given: '$.info', + then: { + function: truthy, + }, + }, + }, +}; diff --git a/packages/core/src/ruleset/__tests__/validation.test.ts b/packages/core/src/ruleset/validation/__tests__/validation.test.ts similarity index 85% rename from packages/core/src/ruleset/__tests__/validation.test.ts rename to packages/core/src/ruleset/validation/__tests__/validation.test.ts index f87d22ea4..81e7715c5 100644 --- a/packages/core/src/ruleset/__tests__/validation.test.ts +++ b/packages/core/src/ruleset/validation/__tests__/validation.test.ts @@ -1,14 +1,16 @@ -import { schema, truthy } from '@stoplight/spectral-functions'; -import { Format } from '../format'; -import { assertValidRuleset, RulesetValidationError } from '../validation'; -import { RulesetDefinition, RulesetOverridesDefinition } from '../types'; -const invalidRuleset = require('./__fixtures__/invalid-ruleset.json'); -const validRuleset = require('./__fixtures__/valid-flat-ruleset.json'); +import { truthy } from '@stoplight/spectral-functions'; +import type { Format } from '../../format'; + +import { assertValidRuleset, RulesetValidationError } from '../index'; +import invalidRuleset from './__fixtures__/invalid-ruleset'; +import validRuleset from './__fixtures__/valid-flat-ruleset'; + +import { RulesetDefinition, RulesetOverridesDefinition } from '../../types'; const formatA: Format = () => false; const formatB: Format = () => false; -describe('Ruleset Validation', () => { +describe('JS Ruleset Validation', () => { it('given primitive type, throws', () => { expect(assertValidRuleset.bind(null, null)).toThrow('Provided ruleset is not an object'); expect(assertValidRuleset.bind(null, 2)).toThrow('Provided ruleset is not an object'); @@ -144,22 +146,6 @@ Error at #/rules/rule-with-invalid-enum/severity: the value has to be one of: 0, ).not.toThrow(); }); - it('recognizes string extends syntax', () => { - expect( - assertValidRuleset.bind(null, { - rules: { - foo: { - given: '$', - then: { - function: schema, - functionOptions: {}, - }, - }, - }, - }), - ).not.toThrow(); - }); - it.each<[unknown, string]>([ [[[{ rules: {} }, 'test']], `Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`], [ @@ -655,3 +641,120 @@ Error at #/parserOptions/incompatibleValues: the value has to be one of: 0, 1, 2 }); }); }); + +// we only check the most notable differences here, since the rest of the validation process is common to both JS and JSON +describe('JSON Ruleset Validation', () => { + it('recognizes valid array-ish extends syntax', () => { + expect( + assertValidRuleset.bind( + null, + { + extends: [['rulesetA', 'off'], 'rulesetB'], + rules: {}, + }, + 'json', + ), + ).not.toThrow(); + }); + + it.each<[unknown, string]>([ + [[['test', 'test']], `Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`], + [ + [['bar', 'test'], {}], + `Error at #/extends/1: must be string +Error at #/extends/0/1: allowed types are "off", "recommended" and "all"`, + ], + ])('recognizes invalid array-ish extends syntax %p', (_extends, message) => { + expect( + assertValidRuleset.bind( + null, + { + extends: _extends, + }, + 'json', + ), + ).toThrow(new RulesetValidationError(message)); + }); + + it('recognizes valid ruleset formats syntax', () => { + expect( + assertValidRuleset.bind( + null, + { + formats: ['oas2'], + rules: {}, + }, + 'json', + ), + ).not.toThrow(); + }); + + it.each([ + [ + [2, 'a'], + `Error at #/formats/0: must be a valid format +Error at #/formats/1: must be a valid format`, + ], + [2, 'Error at #/formats: must be an array of formats'], + [[''], 'Error at #/formats/0: must be a valid format'], + ])('recognizes invalid ruleset %p formats syntax', (formats, error) => { + expect( + assertValidRuleset.bind( + null, + { + formats, + rules: {}, + }, + 'json', + ), + ).toThrow(new RulesetValidationError(error)); + }); + + it('recognizes valid rule formats syntax', () => { + expect( + assertValidRuleset.bind( + null, + { + formats: ['json-schema-loose'], + rules: { + rule: { + given: '$.info', + then: { + function: 'truthy', + }, + formats: ['oas2'], + }, + }, + }, + 'json', + ), + ).not.toThrow(); + }); + + it.each([ + [ + [2, 'a'], + `Error at #/rules/rule/formats/0: must be a valid format +Error at #/rules/rule/formats/1: must be a valid format`, + ], + [2, 'Error at #/rules/rule/formats: must be an array of formats'], + ])('recognizes invalid rule %p formats syntax', (formats, error) => { + expect( + assertValidRuleset.bind( + null, + { + rules: { + rule: { + given: '$.info', + then: { + function: 'truthy', + }, + formats, + }, + }, + }, + 'json', + ), + ).toThrow(new RulesetValidationError(error)); + }); +}); diff --git a/packages/core/src/ruleset/validation/ajv.ts b/packages/core/src/ruleset/validation/ajv.ts new file mode 100644 index 000000000..88fecdadc --- /dev/null +++ b/packages/core/src/ruleset/validation/ajv.ts @@ -0,0 +1,66 @@ +import Ajv, { _, ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; +import addErrors from 'ajv-errors'; +import * as ruleSchema from '../meta/rule.schema.json'; +import * as shared from '../meta/shared.json'; +import * as rulesetSchema from '../meta/ruleset.schema.json'; +import * as jsExtensions from '../meta/js-extensions.json'; +import * as jsonExtensions from '../meta/json-extensions.json'; + +const message = _`'spectral-message'`; + +const validators: { [key in 'js' | 'json']: null | ValidateFunction } = { + js: null, + json: null, +}; + +export function createValidator(format: 'js' | 'json'): ValidateFunction { + const existingValidator = validators[format]; + if (existingValidator !== null) { + return existingValidator; + } + + const ajv = new Ajv({ + allErrors: true, + strict: true, + strictRequired: false, + keywords: ['$anchor'], + schemas: [ruleSchema, shared], + }); + addFormats(ajv); + addErrors(ajv); + ajv.addKeyword({ + keyword: 'spectral-runtime', + schemaType: 'string', + error: { + message(ctx) { + return _`${ctx.data}[Symbol.for(${message})]`; + }, + }, + code(cxt) { + const { data } = cxt; + + switch (cxt.schema as unknown) { + case 'format': + cxt.fail(_`typeof ${data} !== "function"`); + break; + case 'ruleset-function': + cxt.pass(_`typeof ${data}.function === "function"`); + cxt.pass( + _`(() => { try { ${data}.function.validator && ${data}.function.validator('functionOptions' in ${data} ? ${data} : null); } catch (e) { ${data}[${message}] = e.message } })()`, + ); + break; + } + }, + }); + + if (format === 'js') { + ajv.addSchema(jsExtensions); + } else { + ajv.addSchema(jsonExtensions); + } + + const validator = ajv.compile(rulesetSchema); + validators[format] = validator; + return validator; +} diff --git a/packages/core/src/ruleset/validation/assertions.ts b/packages/core/src/ruleset/validation/assertions.ts new file mode 100644 index 000000000..f8702feb9 --- /dev/null +++ b/packages/core/src/ruleset/validation/assertions.ts @@ -0,0 +1,33 @@ +import { isPlainObject } from '@stoplight/json'; +import { createValidator } from './ajv'; +import { RulesetAjvValidationError, RulesetValidationError } from './errors'; +import type { FileRuleDefinition, RuleDefinition, RulesetDefinition } from '../types'; + +export function assertValidRuleset( + ruleset: unknown, + format: 'js' | 'json' = 'js', +): asserts ruleset is RulesetDefinition { + if (!isPlainObject(ruleset)) { + throw new Error('Provided ruleset is not an object'); + } + + if (!('rules' in ruleset) && !('extends' in ruleset) && !('overrides' in ruleset)) { + throw new RulesetValidationError('Ruleset must have rules or extends or overrides defined'); + } + + const validate = createValidator(format); + + if (!validate(ruleset)) { + throw new RulesetAjvValidationError(ruleset, validate.errors ?? []); + } +} + +export function isValidRule(rule: FileRuleDefinition): rule is RuleDefinition { + return typeof rule === 'object' && rule !== null && !Array.isArray(rule) && ('given' in rule || 'then' in rule); +} + +export function assertValidRule(rule: FileRuleDefinition): asserts rule is RuleDefinition { + if (!isValidRule(rule)) { + throw new TypeError('Invalid rule'); + } +} diff --git a/packages/core/src/ruleset/validation/errors.ts b/packages/core/src/ruleset/validation/errors.ts new file mode 100644 index 000000000..3f3d120ef --- /dev/null +++ b/packages/core/src/ruleset/validation/errors.ts @@ -0,0 +1,65 @@ +import { ErrorObject } from 'ajv'; +import { printPath, PrintStyle } from '@stoplight/spectral-runtime'; + +export class RulesetValidationError extends Error { + constructor(public readonly message: string) { + super(message); + } +} + +const RULE_INSTANCE_PATH = /^\/rules\/[^/]+/; +const GENERIC_INSTANCE_PATH = /^\/(?:aliases|extends|overrides(?:\/\d+\/extends)?)/; + +export class RulesetAjvValidationError extends RulesetValidationError { + constructor(public ruleset: Record, public errors: ErrorObject[]) { + super(RulesetAjvValidationError.serializeAjvErrors(ruleset, errors)); + } + + public static serializeAjvErrors(ruleset: Record, errors: ErrorObject[]): string { + const sortedErrors = [...errors] + .sort((errorA, errorB) => { + const diff = errorA.instancePath.length - errorB.instancePath.length; + return diff === 0 ? (errorA.keyword === 'errorMessage' && errorB.keyword !== 'errorMessage' ? -1 : 0) : diff; + }) + .filter((error, i, sortedErrors) => i === 0 || sortedErrors[i - 1].instancePath !== error.instancePath); + + const filteredErrors: ErrorObject[] = []; + + l: for (let i = 0; i < sortedErrors.length; i++) { + const error = sortedErrors[i]; + const prevError = i === 0 ? null : sortedErrors[i - 1]; + + if (GENERIC_INSTANCE_PATH.test(error.instancePath)) { + let x = 1; + while (i + x < sortedErrors.length) { + if ( + sortedErrors[i + x].instancePath.startsWith(error.instancePath) || + !GENERIC_INSTANCE_PATH.test(sortedErrors[i + x].instancePath) + ) { + continue l; + } + + x++; + } + } else if (prevError === null) { + filteredErrors.push(error); + continue; + } else { + const match = RULE_INSTANCE_PATH.exec(error.instancePath); + + if (match !== null && match[0] !== match.input && match[0] === prevError.instancePath) { + filteredErrors.pop(); + } + } + + filteredErrors.push(error); + } + + return filteredErrors + .map( + ({ message, instancePath }) => + `Error at ${printPath(instancePath.slice(1).split('/'), PrintStyle.Pointer)}: ${message ?? ''}`, + ) + .join('\n'); + } +} diff --git a/packages/core/src/ruleset/validation/index.ts b/packages/core/src/ruleset/validation/index.ts new file mode 100644 index 000000000..b7eb566c4 --- /dev/null +++ b/packages/core/src/ruleset/validation/index.ts @@ -0,0 +1,2 @@ +export { RulesetValidationError } from './errors'; +export { assertValidRuleset } from './assertions';