diff --git a/docs/guides/4-custom-rulesets.md b/docs/guides/4-custom-rulesets.md index d738f263e..9d1a1e5c2 100644 --- a/docs/guides/4-custom-rulesets.md +++ b/docs/guides/4-custom-rulesets.md @@ -346,7 +346,7 @@ For now the JSON, YAML, and JS, are all being maintained, and there are no curre Targeting certain parts of an OpenAPI spec is powerful but it can become cumbersome to write and repeat complex JSONPath expressions across various rules. Define aliases for commonly used JSONPath expressions on a global level which can then be reused across the ruleset. -Aliases can be defined in an array of key-value pairs at the root level of the ruleset. +Aliases can be defined in an array of key-value pairs at the root level of the ruleset, or alternatively, within an override. It's similar to `given`, with the notable difference being the possibility to distinguish between different formats. **Example** @@ -415,6 +415,48 @@ aliases: Rulesets can then reference aliases in the [given](#given) keyword, either in full: `"given": "#Paths"`, or use it as a prefix for further JSONPath syntax, like dot notation: `"given": "#ParameterObject.name"`. +Bear in mind that an alias has to be explicitly defined in either at the top-level or inside an override. +This is to avoid ambiguity. + +```yaml +aliases: + Stoplight: + - "$..stoplight" +overrides: + - files: + - "*.yaml" + rules: + value-matches-stoplight: + message: Value must contain Stoplight + given: "#Stoplight" # valid because declared at the root + severity: error + then: + field: description + function: pattern + functionOptions: + match: Stoplight + - files: + - "**/*.json" + aliases: + Value: + - "$..value" + rules: + truthy-stoplight-property: + message: Value must contain Stoplight + given: "#Value" # valid because declared within the override block + severity: error + then: + function: truthy + - files: + - legacy/**/*.json + rules: + falsy-value: + given: "#Value" # invalid because undeclared both at the top-level and the override. Note that this could be technically resolvable for some JSON documents, because the previous override block has the alias, but to spare some headaches, we demand an alias to be explicitly defined. + severity: error + then: + function: falsy +``` + > This will be followed by our core rulesets providing a common set of aliases for OpenAPI and AsyncAPI so that our users don't have to do the work at all. If you have ideas about what kind of aliases could be useful leave your thoughts [here](https://roadmap.stoplight.io). ## Overrides diff --git a/docs/reference/error-handling.md b/docs/reference/error-handling.md index d21d93c3b..094f8e740 100644 --- a/docs/reference/error-handling.md +++ b/docs/reference/error-handling.md @@ -10,6 +10,7 @@ - a rule in the ruleset: - had an invalid `given`, i.e. the JSON Path expression is not valid from syntax's standpoint - the ruleset contains `except` entries and the input is passed through stdin +- a JSON Path alias cannot be resolved ### Runtime diff --git a/packages/cli/src/services/__tests__/linter.test.ts b/packages/cli/src/services/__tests__/linter.test.ts index 251b7be97..118a93546 100644 --- a/packages/cli/src/services/__tests__/linter.test.ts +++ b/packages/cli/src/services/__tests__/linter.test.ts @@ -229,10 +229,6 @@ describe('Linter service', () => { run(`lint ${validCustomOas3SpecPath} -r ${invalidNestedRulesetPath}`), ).rejects.toThrowAggregateError( new AggregateError([ - new RulesetValidationError('must be equal to one of the allowed values', [ - 'rules', - 'rule-with-invalid-enum', - ]), new RulesetValidationError('the rule must have at least "given" and "then" properties', [ 'rules', 'rule-without-given-nor-them', @@ -242,10 +238,6 @@ describe('Linter service', () => { 'rule-with-invalid-enum', 'type', ]), - new RulesetValidationError('must be equal to one of the allowed values', [ - 'rules', - 'rule-without-given-nor-them', - ]), new RulesetValidationError( 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', ['rules', 'rule-with-invalid-enum', 'severity'], @@ -265,10 +257,6 @@ describe('Linter service', () => { it('outputs "invalid ruleset" error', () => { return expect(run(`lint ${validOas3SpecPath} -r ${invalidRulesetPath}`)).rejects.toThrowAggregateError( new AggregateError([ - new RulesetValidationError('must be equal to one of the allowed values', [ - 'rules', - 'rule-with-invalid-enum', - ]), new RulesetValidationError('the rule must have at least "given" and "then" properties', [ 'rules', 'rule-without-given-nor-them', @@ -278,10 +266,6 @@ describe('Linter service', () => { 'rule-with-invalid-enum', 'type', ]), - new RulesetValidationError('must be equal to one of the allowed values', [ - 'rules', - 'rule-without-given-nor-them', - ]), new RulesetValidationError( 'the value has to be one of: 0, 1, 2, 3 or "error", "warn", "info", "hint", "off"', ['rules', 'rule-with-invalid-enum', 'severity'], diff --git a/packages/core/src/ruleset/__tests__/__fixtures__/overrides/aliases/scope.ts b/packages/core/src/ruleset/__tests__/__fixtures__/overrides/aliases/scope.ts index c0ffad10e..c36b6a9fc 100644 --- a/packages/core/src/ruleset/__tests__/__fixtures__/overrides/aliases/scope.ts +++ b/packages/core/src/ruleset/__tests__/__fixtures__/overrides/aliases/scope.ts @@ -44,6 +44,9 @@ const ruleset: RulesetDefinition = { }, { files: ['legacy/**/*.json'], + aliases: { + Value: ['$..value'], + }, rules: { 'falsy-value': { given: '#Value', diff --git a/packages/core/src/ruleset/__tests__/ruleset.test.ts b/packages/core/src/ruleset/__tests__/ruleset.test.ts index 5116f320c..7bfdb8130 100644 --- a/packages/core/src/ruleset/__tests__/ruleset.test.ts +++ b/packages/core/src/ruleset/__tests__/ruleset.test.ts @@ -1,7 +1,10 @@ +import '@stoplight/spectral-test-utils/matchers'; + import { oas2 } from '@stoplight/spectral-formats'; import { pattern, truthy } from '@stoplight/spectral-functions'; import * as path from '@stoplight/path'; import { DiagnosticSeverity } from '@stoplight/types'; +import AggregateError = require('es-aggregate-error'); import { Ruleset } from '../ruleset'; import { RulesetDefinition } from '../types'; @@ -1266,7 +1269,11 @@ describe('Ruleset', () => { }, }, }), - ).toThrowError(ReferenceError('Alias "PathItem-" does not exist')); + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('Alias "PathItem-" does not exist', ['rules', 'valid-path', 'given']), + ]), + ); }); it('given circular alias, should throw', () => { @@ -1288,8 +1295,13 @@ describe('Ruleset', () => { }, }, }), - ).toThrowError( - ReferenceError('Alias "Test" is circular. Resolution stack: Test -> Contact -> Info -> Root -> Info'), + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError( + 'Alias "Test" is circular. Resolution stack: Test -> Contact -> Info -> Root -> Info', + ['rules', 'valid-path', 'given'], + ), + ]), ); }); @@ -1321,7 +1333,17 @@ describe('Ruleset', () => { }, }, }), - ).toThrowError(ReferenceError('Alias "PathItem" does not exist')); + ).toThrowAggregateError( + new AggregateError([ + new RulesetValidationError('Alias "PathItem" does not exist', ['rules', 'valid-path', 'given']), + new RulesetValidationError('Alias "Name" does not exist', ['rules', 'valid-name-and-description', 'given']), + new RulesetValidationError(`Alias "Description" does not exist`, [ + 'rules', + 'valid-name-and-description', + 'given', + ]), + ]), + ); }); describe('scoped aliases', () => { diff --git a/packages/core/src/ruleset/alias.ts b/packages/core/src/ruleset/alias.ts new file mode 100644 index 000000000..b70de064a --- /dev/null +++ b/packages/core/src/ruleset/alias.ts @@ -0,0 +1,83 @@ +import { isScopedAliasDefinition, isSimpleAliasDefinition } from './utils/guards'; +import type { RulesetScopedAliasDefinition } from './types'; + +const ALIAS = /^#([A-Za-z0-9_-]+)/; + +export function resolveAliasForFormats( + { targets }: RulesetScopedAliasDefinition, + formats: Set | null, +): string[] | null { + if (formats === null || formats.size === 0) { + return null; + } + + // we start from the end to be consistent with overrides etc. - we generally tend to pick the "last" value. + for (let i = targets.length - 1; i >= 0; i--) { + const target = targets[i]; + for (const format of target.formats) { + if (formats.has(format)) { + return target.given; + } + } + } + + return null; +} + +export function resolveAlias( + aliases: Record | null, + expression: string, + formats: Set | null, +): string[] { + return _resolveAlias(aliases, expression, formats, new Set()); +} + +function _resolveAlias( + aliases: Record | null, + expression: string, + formats: Set | null, + stack: Set, +): string[] { + const resolvedExpressions: string[] = []; + + if (expression.startsWith('#')) { + const alias = ALIAS.exec(expression)?.[1]; + + if (alias === void 0 || alias === null) { + throw new ReferenceError(`Alias must match /^#([A-Za-z0-9_-]+)/`); + } + + if (stack.has(alias)) { + const _stack = [...stack, alias]; + throw new ReferenceError(`Alias "${_stack[0]}" is circular. Resolution stack: ${_stack.join(' -> ')}`); + } + + stack.add(alias); + + if (aliases === null || !(alias in aliases)) { + throw new ReferenceError(`Alias "${alias}" does not exist`); + } + + const aliasValue = aliases[alias]; + let actualAliasValue: string[] | null; + if (isSimpleAliasDefinition(aliasValue)) { + actualAliasValue = aliasValue; + } else if (isScopedAliasDefinition(aliasValue)) { + actualAliasValue = resolveAliasForFormats(aliasValue, formats); + } else { + actualAliasValue = null; + } + + if (actualAliasValue !== null) { + resolvedExpressions.push( + ...actualAliasValue.flatMap(item => + _resolveAlias(aliases, item + expression.slice(alias.length + 1), formats, new Set([...stack])), + ), + ); + } + } else { + resolvedExpressions.push(expression); + } + + return resolvedExpressions; +} diff --git a/packages/core/src/ruleset/meta/rule.schema.json b/packages/core/src/ruleset/meta/rule.schema.json index 4372cacff..342f84a56 100644 --- a/packages/core/src/ruleset/meta/rule.schema.json +++ b/packages/core/src/ruleset/meta/rule.schema.json @@ -21,72 +21,78 @@ "$ref": "shared#severity" } }, - "oneOf": [ - { - "properties": { - "description": { - "type": "string" - }, - "documentationUrl": { - "type": "string", - "format": "url", - "errorMessage": "must be a valid URL" - }, - "recommended": { - "type": "boolean" - }, - "given": { - "$ref": "shared#given" - }, - "resolved": { - "type": "boolean" - }, - "severity": { - "$ref": "#/$defs/Severity" - }, - "message": { + "if": { + "type": "object" + }, + "then": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "documentationUrl": { + "type": "string", + "format": "url", + "errorMessage": "must be a valid URL" + }, + "recommended": { + "type": "boolean" + }, + "given": { + "$ref": "shared#given" + }, + "resolved": { + "type": "boolean" + }, + "severity": { + "$ref": "#/$defs/Severity" + }, + "message": { + "type": "string" + }, + "tags": { + "items": { "type": "string" }, - "tags": { - "items": { - "type": "string" - }, + "type": "array" + }, + "formats": { + "$ref": "shared#formats" + }, + "then": { + "if": { "type": "array" }, - "formats": { - "$ref": "shared#formats" - }, "then": { - "anyOf": [ - { - "$ref": "#/$defs/Then" - }, - { - "items": { - "$ref": "#/$defs/Then" - }, - "type": "array" - } - ] + "type": "array", + "items": { + "$ref": "#/$defs/Then" + } }, - "type": { - "enum": ["style", "validation"], - "type": "string", - "errorMessage": "allowed types are \"style\" and \"validation\"" + "else": { + "$ref": "#/$defs/Then" } }, - "required": ["given", "then"], - "type": "object", - "additionalProperties": false, - "errorMessage": { - "required": "the rule must have at least \"given\" and \"then\" properties" + "type": { + "enum": ["style", "validation"], + "type": "string", + "errorMessage": "allowed types are \"style\" and \"validation\"" } }, - { - "$ref": "shared#/$defs/HumanReadableSeverity" - }, - { - "type": "boolean" + "required": ["given", "then"], + "additionalProperties": false, + "errorMessage": { + "required": "the rule must have at least \"given\" and \"then\" properties" } - ] + }, + "else": { + "oneOf": [ + { + "$ref": "shared#/$defs/HumanReadableSeverity" + }, + { + "type": "boolean" + } + ] + } } diff --git a/packages/core/src/ruleset/meta/shared.json b/packages/core/src/ruleset/meta/shared.json index e46e507cf..bd8402c30 100644 --- a/packages/core/src/ruleset/meta/shared.json +++ b/packages/core/src/ruleset/meta/shared.json @@ -50,9 +50,26 @@ }, "PathExpression": { "$id": "path-expression", - "type": "string", - "pattern": "^[$#]", - "errorMessage": "must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset" + "if": { + "type": "string" + }, + "then": { + "type": "string", + "if": { + "pattern": "^#" + }, + "then": { + "x-spectral-runtime": "alias" + }, + "else": { + "pattern": "^\\$", + "errorMessage": "must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset" + } + }, + "else": { + "not": {}, + "errorMessage": "must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset" + } } } } diff --git a/packages/core/src/ruleset/rule.ts b/packages/core/src/ruleset/rule.ts index d7a73608a..3cc581337 100644 --- a/packages/core/src/ruleset/rule.ts +++ b/packages/core/src/ruleset/rule.ts @@ -7,19 +7,10 @@ import { printValue } from '@stoplight/spectral-runtime'; import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity } from './utils/severity'; import { Ruleset } from './ruleset'; import { Format } from './format'; -import type { - HumanReadableDiagnosticSeverity, - IRuleThen, - RuleDefinition, - RulesetAliasesDefinition, - RulesetScopedAliasDefinition, - Stringifable, -} from './types'; +import type { HumanReadableDiagnosticSeverity, IRuleThen, RuleDefinition, Stringifable } from './types'; import { minimatch } from './utils/minimatch'; import { Formats } from './formats'; -import { isSimpleAliasDefinition } from './utils/guards'; - -const ALIAS = /^#([A-Za-z0-9_-]+)/; +import { resolveAlias } from './alias'; export interface IRule { description: string | null; @@ -142,84 +133,15 @@ export class Rule implements IRule { const actualGiven = Array.isArray(given) ? given : [given]; this.#given = this.owner.hasComplexAliases ? actualGiven - : actualGiven.flatMap(expr => Rule.#resolveAlias(this.owner.aliases, expr, null, new Set())).filter(isString); + : actualGiven.flatMap(expr => resolveAlias(this.owner.aliases, expr, null)).filter(isString); } public getGivenForFormats(formats: Set | null): string[] { return this.owner.hasComplexAliases - ? this.#given.flatMap(expr => Rule.#resolveAlias(this.owner.aliases, expr, formats, new Set())) + ? this.#given.flatMap(expr => resolveAlias(this.owner.aliases, expr, formats)) : this.#given; } - static #resolveAlias( - aliases: RulesetAliasesDefinition | null, - expr: string, - formats: Set | null, - stack: Set, - ): string[] { - const resolvedExpressions: string[] = []; - - if (expr.startsWith('#')) { - const alias = ALIAS.exec(expr)?.[1]; - - if (alias === void 0 || alias === null) { - throw new ReferenceError(`"${this.name}" rule references an invalid alias`); - } - - if (stack.has(alias)) { - const _stack = [...stack, alias]; - throw new ReferenceError(`Alias "${_stack[0]}" is circular. Resolution stack: ${_stack.join(' -> ')}`); - } - - stack.add(alias); - - if (aliases === null || !(alias in aliases)) { - throw new ReferenceError(`Alias "${alias}" does not exist`); - } - - const aliasValue = aliases[alias]; - let actualAliasValue: string[] | null; - if (isSimpleAliasDefinition(aliasValue)) { - actualAliasValue = aliasValue; - } else { - actualAliasValue = Rule.#resolveAliasForFormats(aliasValue, formats); - } - - if (actualAliasValue !== null) { - resolvedExpressions.push( - ...actualAliasValue.flatMap(item => - Rule.#resolveAlias(aliases, item + expr.slice(alias.length + 1), formats, new Set([...stack])), - ), - ); - } - } else { - resolvedExpressions.push(expr); - } - - return resolvedExpressions; - } - - static #resolveAliasForFormats( - { targets }: RulesetScopedAliasDefinition, - formats: Set | null, - ): string[] | null { - if (formats === null || formats.size === 0) { - return null; - } - - // we start from the end to be consistent with overrides etc. - we generally tend to pick the "last" value. - for (let i = targets.length - 1; i >= 0; i--) { - const target = targets[i]; - for (const format of target.formats) { - if (formats.has(format)) { - return target.given; - } - } - } - - return null; - } - public matchesFormat(formats: Set | null): boolean { if (this.formats === null) { return true; diff --git a/packages/core/src/ruleset/utils/guards.ts b/packages/core/src/ruleset/utils/guards.ts index 8bc05279f..45646adf2 100644 --- a/packages/core/src/ruleset/utils/guards.ts +++ b/packages/core/src/ruleset/utils/guards.ts @@ -1,7 +1,22 @@ -import { RulesetScopedAliasDefinition } from '../types'; +import { isPlainObject } from '@stoplight/json'; +import { isString } from 'lodash'; +import type { RulesetScopedAliasDefinition } from '../types'; -export function isSimpleAliasDefinition( - alias: string | string[] | RulesetScopedAliasDefinition, -): alias is string | string[] { - return typeof alias === 'string' || Array.isArray(alias); +export function isSimpleAliasDefinition(alias: unknown): alias is string[] { + return Array.isArray(alias); +} + +export function isValidAliasTarget( + target: Record, +): target is RulesetScopedAliasDefinition['targets'][number] { + const formats = target.formats; + if (!Array.isArray(formats) && !(formats instanceof Set)) { + return false; + } + + return Array.isArray(target.given) && target.given.every(isString); +} + +export function isScopedAliasDefinition(alias: unknown): alias is RulesetScopedAliasDefinition { + return isPlainObject(alias) && Array.isArray(alias.targets) && alias.targets.every(isValidAliasTarget); } diff --git a/packages/core/src/ruleset/validation/__tests__/validation.test.ts b/packages/core/src/ruleset/validation/__tests__/validation.test.ts index a9894422d..a729f2f68 100644 --- a/packages/core/src/ruleset/validation/__tests__/validation.test.ts +++ b/packages/core/src/ruleset/validation/__tests__/validation.test.ts @@ -370,11 +370,12 @@ describe('JS Ruleset Validation', () => { }, ); - it.each(['#Info', '#i', '#Info.contact', '#Info[*]'])('recognizes %s as a valid value of an alias', value => { + it.each(['#Info', '#Info.contact', '#Info[*]'])('recognizes %s as a valid value of an alias', value => { expect( assertValidRuleset.bind(null, { rules: {}, aliases: { + Info: ['$'], alias: [value], }, }), @@ -506,11 +507,26 @@ describe('JS Ruleset Validation', () => { }, ); - it.each(['#Info', '#i', '#Info.contact', '#Info[*]'])('recognizes %s as a valid value of an alias', value => { + it.each(['#Info', '#Info.contact', '#Info[*]'])('recognizes %s as a valid value of an alias', value => { expect( assertValidRuleset.bind(null, { - rules: {}, + rules: { + a: { + given: '#alias', + then: { + function: truthy, + }, + }, + }, aliases: { + Info: { + targets: [ + { + formats: [formatA], + given: ['$'], + }, + ], + }, alias: { targets: [ { @@ -632,7 +648,7 @@ describe('JS Ruleset Validation', () => { targets: [ { formats: [formatA], - given: ['#.definitions[*]'], + given: ['$.definitions[*]'], }, { formats: [formatA, formatB], diff --git a/packages/core/src/ruleset/validation/ajv.ts b/packages/core/src/ruleset/validation/ajv.ts index 56227d0a2..0686aee26 100644 --- a/packages/core/src/ruleset/validation/ajv.ts +++ b/packages/core/src/ruleset/validation/ajv.ts @@ -1,4 +1,5 @@ import Ajv, { _, ValidateFunction } from 'ajv'; +import names from 'ajv/dist/compile/names'; import addFormats from 'ajv-formats'; import addErrors from 'ajv-errors'; import * as ruleSchema from '../meta/rule.schema.json'; @@ -6,8 +7,8 @@ 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'`; +import { validateAlias } from './validators/alias'; +import { validateFunction } from './validators/function'; const validators: { [key in 'js' | 'json']: null | ValidateFunction } = { js: null, @@ -26,6 +27,7 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction { strictRequired: false, keywords: ['$anchor'], schemas: [ruleSchema, shared], + passContext: true, }); addFormats(ajv); addErrors(ajv); @@ -34,10 +36,10 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction { schemaType: 'string', error: { message(cxt) { - return _`${cxt.data}[Symbol.for(${message})]`; + return _`${cxt.params?.message !== void 0 ? cxt.params.message : ''}`; }, params(cxt) { - return _`${cxt.data}[Symbol.for(${message})] ? { "errors": ${cxt.data}[Symbol.for(${message})].errors || [${cxt.data}[Symbol.for(${message})]] } : {}`; + return _`{ errors: ${cxt.params?.errors !== void 0 && cxt.params.errors} || [] }`; }, }, code(cxt) { @@ -47,12 +49,26 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction { 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}.functionOptions : null); return true; } catch (e) { ${data}[Symbol.for(${message})] = e; return false; } })()`, + case 'ruleset-function': { + const fn = cxt.gen.const( + 'spectralFunction', + _`this.validateFunction(${data}.function, ${data}.functionOptions === void 0 ? null : ${data}.functionOptions, ${names.instancePath})`, + ); + cxt.gen.if(_`${fn} !== void 0`); + cxt.error(false, { errors: fn }); + cxt.gen.endIf(); + break; + } + case 'alias': { + const alias = cxt.gen.const( + 'spectralAlias', + _`this.validateAlias(${names.rootData}, ${data}, ${names.instancePath})`, ); + cxt.gen.if(_`${alias} !== void 0`); + cxt.error(false, { errors: alias }); + cxt.gen.endIf(); break; + } } }, }); @@ -63,7 +79,12 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction { ajv.addSchema(jsonExtensions); } - const validator = ajv.compile(rulesetSchema); + const validator = new Proxy(ajv.compile(rulesetSchema), { + apply(target, thisArg, args: unknown[]): unknown { + return Reflect.apply(target, { validateAlias, validateFunction }, args); + }, + }); + validators[format] = validator; return validator; } diff --git a/packages/core/src/ruleset/validation/errors.ts b/packages/core/src/ruleset/validation/errors.ts index 0f00eb0e3..a80e9da0a 100644 --- a/packages/core/src/ruleset/validation/errors.ts +++ b/packages/core/src/ruleset/validation/errors.ts @@ -1,5 +1,6 @@ import type { ErrorObject } from 'ajv'; import type { IDiagnostic, JsonPath } from '@stoplight/types'; +import { isAggregateError } from '../../guards/isAggregateError'; type RulesetValidationSingleError = Pick; @@ -24,7 +25,9 @@ export function convertAjvErrors(errors: ErrorObject[]): RulesetValidationError[ l: for (let i = 0; i < sortedErrors.length; i++) { const error = sortedErrors[i]; - const prevError = i === 0 ? null : sortedErrors[i - 1]; + const prevError = filteredErrors.length === 0 ? null : filteredErrors[filteredErrors.length - 1]; + + if (error.keyword === 'if') continue; if (GENERIC_INSTANCE_PATH.test(error.instancePath)) { let x = 1; @@ -54,7 +57,15 @@ export function convertAjvErrors(errors: ErrorObject[]): RulesetValidationError[ return filteredErrors.flatMap(error => error.keyword === 'x-spectral-runtime' - ? (error.params.errors as RulesetValidationError[]) + ? flatErrors(error.params.errors) : new RulesetValidationError(error.message ?? 'unknown error', error.instancePath.slice(1).split('/')), ); } + +function flatErrors(error: RulesetValidationError | AggregateError): RulesetValidationError | RulesetValidationError[] { + if (isAggregateError(error)) { + return error.errors.flatMap(flatErrors); + } + + return error; +} diff --git a/packages/core/src/ruleset/validation/validators/alias.ts b/packages/core/src/ruleset/validation/validators/alias.ts new file mode 100644 index 000000000..2d6f494e0 --- /dev/null +++ b/packages/core/src/ruleset/validation/validators/alias.ts @@ -0,0 +1,39 @@ +import { isPlainObject } from '@stoplight/json'; +import { get } from 'lodash'; +import { resolveAlias } from '../../alias'; +import { Formats } from '../../formats'; +import { wrapError } from './common/error'; + +function getOverrides(overrides: unknown, key: string): Record | null { + if (!Array.isArray(overrides)) return null; + + const index = Number(key); + if (Number.isNaN(index)) return null; + if (index < 0 && index >= overrides.length) return null; + + const actualOverrides: unknown = overrides[index]; + return isPlainObject(actualOverrides) && isPlainObject(actualOverrides.aliases) ? actualOverrides.aliases : null; +} + +export function validateAlias( + ruleset: { aliases?: Record; overrides?: Record }, + alias: string, + path: string, +): Error | void { + try { + const parsedPath = path.slice(1).split('/'); + const formats: unknown = get(ruleset, [...parsedPath.slice(0, parsedPath.indexOf('rules') + 2), 'formats']); + + const aliases = + parsedPath[0] === 'overrides' + ? { + ...ruleset.aliases, + ...getOverrides(ruleset.overrides, parsedPath[1]), + } + : ruleset.aliases; + + resolveAlias(aliases ?? null, alias, Array.isArray(formats) ? new Formats(formats) : null); + } catch (ex) { + return wrapError(ex, path); + } +} diff --git a/packages/core/src/ruleset/validation/validators/common/error.ts b/packages/core/src/ruleset/validation/validators/common/error.ts new file mode 100644 index 000000000..214065984 --- /dev/null +++ b/packages/core/src/ruleset/validation/validators/common/error.ts @@ -0,0 +1,24 @@ +import { isError } from 'lodash'; +import AggregateError from 'es-aggregate-error'; + +import { RulesetValidationError } from '../../errors'; +import { isAggregateError } from '../../../../guards/isAggregateError'; + +function toRulesetValidationError(this: ReadonlyArray, ex: unknown): RulesetValidationError { + if (ex instanceof RulesetValidationError) { + ex.path.unshift(...this); + return ex; + } + + return new RulesetValidationError(isError(ex) ? ex.message : String(ex), [...this]); +} + +export function wrapError(ex: unknown, path: string): Error { + const parsedPath = path.slice(1).split('/'); + + if (isAggregateError(ex)) { + return new AggregateError(ex.errors.map(toRulesetValidationError, parsedPath)); + } + + return toRulesetValidationError.call(parsedPath, ex); +} diff --git a/packages/core/src/ruleset/validation/validators/function.ts b/packages/core/src/ruleset/validation/validators/function.ts new file mode 100644 index 000000000..2c558d990 --- /dev/null +++ b/packages/core/src/ruleset/validation/validators/function.ts @@ -0,0 +1,27 @@ +import type { RulesetFunction, RulesetFunctionWithValidator } from '../../../types'; +import { wrapError } from './common/error'; + +function assertRulesetFunction( + maybeRulesetFunction: unknown, +): asserts maybeRulesetFunction is RulesetFunction | RulesetFunctionWithValidator { + if (typeof maybeRulesetFunction !== 'function') { + throw Error('Function is not defined'); + } +} + +export function validateFunction( + fn: unknown | RulesetFunction | RulesetFunctionWithValidator, + opts: unknown, + path: string, +): Error | void { + try { + assertRulesetFunction(fn); + + if (!('validator' in fn)) return; + + const validator: RulesetFunctionWithValidator['validator'] = fn.validator.bind(fn); + validator(opts); + } catch (ex) { + return wrapError(ex, path); + } +}