Skip to content

Commit c856ee4

Browse files
committed
feat(core): improve alias validation
1 parent daae46c commit c856ee4

File tree

6 files changed

+197
-95
lines changed

6 files changed

+197
-95
lines changed

packages/core/src/ruleset/alias.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { isSimpleAliasDefinition } from './utils/guards';
2+
import type { RulesetScopedAliasDefinition, RulesetAliasesDefinition, RulesetDefinition } from './types';
3+
import type { Ruleset } from './ruleset';
4+
5+
const ALIAS = /^#([A-Za-z0-9_-]+)/;
6+
7+
const CACHE = new (class {
8+
// maybe try to cache array-ish given?
9+
#store = new WeakMap<RulesetAliasesDefinition, Map<string, string[]>>();
10+
11+
add(ruleset: RulesetAliasesDefinition, expression: string, resolvedExpressions: string[]): void {
12+
let existing = this.#store.get(ruleset);
13+
if (!existing) {
14+
existing = new Map();
15+
this.#store.set(ruleset, existing);
16+
}
17+
18+
existing.set(expression, resolvedExpressions);
19+
}
20+
})();
21+
22+
export function resolveAliasForFormats(
23+
{ targets }: RulesetScopedAliasDefinition,
24+
formats: Set<unknown> | null,
25+
): string[] | null {
26+
if (formats === null || formats.size === 0) {
27+
return null;
28+
}
29+
30+
// we start from the end to be consistent with overrides etc. - we generally tend to pick the "last" value.
31+
for (let i = targets.length - 1; i >= 0; i--) {
32+
const target = targets[i];
33+
for (const format of target.formats) {
34+
if (formats.has(format)) {
35+
return target.given;
36+
}
37+
}
38+
}
39+
40+
return null;
41+
}
42+
43+
export function resolveAlias(
44+
ruleset: Ruleset | RulesetDefinition,
45+
expression: string,
46+
formats: Set<unknown> | null,
47+
): string[] {
48+
return _resolveAlias(ruleset, expression, formats, new Set());
49+
}
50+
51+
function _resolveAlias(
52+
ruleset: Ruleset | RulesetDefinition,
53+
expression: string,
54+
formats: Set<unknown> | null,
55+
stack: Set<string>,
56+
): string[] {
57+
const resolvedExpressions: string[] = [];
58+
const { aliases = null } = ruleset;
59+
60+
if (expression.startsWith('#')) {
61+
const alias = ALIAS.exec(expression)?.[1];
62+
63+
if (alias === void 0 || alias === null) {
64+
throw new ReferenceError(`Alias ${Array.from(stack)[0]} does not match /^#([A-Za-z0-9_-]+)/`);
65+
}
66+
67+
if (stack.has(alias)) {
68+
const _stack = [...stack, alias];
69+
throw new ReferenceError(`Alias "${_stack[0]}" is circular. Resolution stack: ${_stack.join(' -> ')}`);
70+
}
71+
72+
stack.add(alias);
73+
74+
if (aliases === null || !(alias in aliases)) {
75+
throw new ReferenceError(`Alias "${alias}" does not exist`);
76+
}
77+
78+
const aliasValue = aliases[alias];
79+
let actualAliasValue: string[] | null;
80+
if (isSimpleAliasDefinition(aliasValue)) {
81+
actualAliasValue = aliasValue;
82+
} else {
83+
actualAliasValue = resolveAliasForFormats(aliasValue, formats);
84+
}
85+
86+
if (actualAliasValue !== null) {
87+
resolvedExpressions.push(
88+
...actualAliasValue.flatMap(item =>
89+
_resolveAlias(ruleset, item + expression.slice(alias.length + 1), formats, new Set([...stack])),
90+
),
91+
);
92+
}
93+
} else {
94+
resolvedExpressions.push(expression);
95+
}
96+
97+
if (aliases !== null) {
98+
CACHE.add(aliases, expression, resolvedExpressions);
99+
}
100+
101+
return resolvedExpressions;
102+
}

packages/core/src/ruleset/meta/shared.json

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,26 @@
5050
},
5151
"PathExpression": {
5252
"$id": "path-expression",
53-
"type": "string",
54-
"pattern": "^[$#]",
55-
"errorMessage": "must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset"
53+
"if": {
54+
"type": "string"
55+
},
56+
"then": {
57+
"type": "string",
58+
"if": {
59+
"pattern": "^#"
60+
},
61+
"then": {
62+
"spectral-runtime": "alias"
63+
},
64+
"else": {
65+
"pattern": "^\\$",
66+
"errorMessage": "must be a valid JSON Path expression"
67+
}
68+
},
69+
"else": {
70+
"not": {},
71+
"errorMessage": "must be a valid JSON Path expression or a reference to the existing Alias optionally paired with a JSON Path expression subset"
72+
}
5673
}
5774
}
5875
}

packages/core/src/ruleset/rule.ts

Lines changed: 4 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,10 @@ import { printValue } from '@stoplight/spectral-runtime';
77
import { DEFAULT_SEVERITY_LEVEL, getDiagnosticSeverity } from './utils/severity';
88
import { Ruleset } from './ruleset';
99
import { Format } from './format';
10-
import type {
11-
HumanReadableDiagnosticSeverity,
12-
IRuleThen,
13-
RuleDefinition,
14-
RulesetAliasesDefinition,
15-
RulesetScopedAliasDefinition,
16-
Stringifable,
17-
} from './types';
10+
import type { HumanReadableDiagnosticSeverity, IRuleThen, RuleDefinition, Stringifable } from './types';
1811
import { minimatch } from './utils/minimatch';
1912
import { Formats } from './formats';
20-
import { isSimpleAliasDefinition } from './utils/guards';
21-
22-
const ALIAS = /^#([A-Za-z0-9_-]+)/;
13+
import { resolveAlias } from './alias';
2314

2415
export interface IRule {
2516
description: string | null;
@@ -142,84 +133,15 @@ export class Rule implements IRule {
142133
const actualGiven = Array.isArray(given) ? given : [given];
143134
this.#given = this.owner.hasComplexAliases
144135
? actualGiven
145-
: actualGiven.flatMap(expr => Rule.#resolveAlias(this.owner.aliases, expr, null, new Set())).filter(isString);
136+
: actualGiven.flatMap(expr => resolveAlias(this.owner, expr, null)).filter(isString);
146137
}
147138

148139
public getGivenForFormats(formats: Set<Format> | null): string[] {
149140
return this.owner.hasComplexAliases
150-
? this.#given.flatMap(expr => Rule.#resolveAlias(this.owner.aliases, expr, formats, new Set()))
141+
? this.#given.flatMap(expr => resolveAlias(this.owner, expr, formats))
151142
: this.#given;
152143
}
153144

154-
static #resolveAlias(
155-
aliases: RulesetAliasesDefinition | null,
156-
expr: string,
157-
formats: Set<Format> | null,
158-
stack: Set<string>,
159-
): string[] {
160-
const resolvedExpressions: string[] = [];
161-
162-
if (expr.startsWith('#')) {
163-
const alias = ALIAS.exec(expr)?.[1];
164-
165-
if (alias === void 0 || alias === null) {
166-
throw new ReferenceError(`"${this.name}" rule references an invalid alias`);
167-
}
168-
169-
if (stack.has(alias)) {
170-
const _stack = [...stack, alias];
171-
throw new ReferenceError(`Alias "${_stack[0]}" is circular. Resolution stack: ${_stack.join(' -> ')}`);
172-
}
173-
174-
stack.add(alias);
175-
176-
if (aliases === null || !(alias in aliases)) {
177-
throw new ReferenceError(`Alias "${alias}" does not exist`);
178-
}
179-
180-
const aliasValue = aliases[alias];
181-
let actualAliasValue: string[] | null;
182-
if (isSimpleAliasDefinition(aliasValue)) {
183-
actualAliasValue = aliasValue;
184-
} else {
185-
actualAliasValue = Rule.#resolveAliasForFormats(aliasValue, formats);
186-
}
187-
188-
if (actualAliasValue !== null) {
189-
resolvedExpressions.push(
190-
...actualAliasValue.flatMap(item =>
191-
Rule.#resolveAlias(aliases, item + expr.slice(alias.length + 1), formats, new Set([...stack])),
192-
),
193-
);
194-
}
195-
} else {
196-
resolvedExpressions.push(expr);
197-
}
198-
199-
return resolvedExpressions;
200-
}
201-
202-
static #resolveAliasForFormats(
203-
{ targets }: RulesetScopedAliasDefinition,
204-
formats: Set<Format> | null,
205-
): string[] | null {
206-
if (formats === null || formats.size === 0) {
207-
return null;
208-
}
209-
210-
// we start from the end to be consistent with overrides etc. - we generally tend to pick the "last" value.
211-
for (let i = targets.length - 1; i >= 0; i--) {
212-
const target = targets[i];
213-
for (const format of target.formats) {
214-
if (formats.has(format)) {
215-
return target.given;
216-
}
217-
}
218-
}
219-
220-
return null;
221-
}
222-
223145
public matchesFormat(formats: Set<Format> | null): boolean {
224146
if (this.formats === null) {
225147
return true;

packages/core/src/ruleset/validation/__tests__/validation.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,14 @@ Error at #/rules/rule/formats/1: must be a valid format`,
453453
it.each(['#Info', '#i', '#Info.contact', '#Info[*]'])('recognizes %s as a valid value of an alias', value => {
454454
expect(
455455
assertValidRuleset.bind(null, {
456-
rules: {},
456+
rules: {
457+
a: {
458+
given: '#alias',
459+
then: {
460+
function: truthy,
461+
},
462+
},
463+
},
457464
aliases: {
458465
alias: {
459466
targets: [

packages/core/src/ruleset/validation/ajv.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Ajv, { _, ValidateFunction } from 'ajv';
2+
import names from 'ajv/dist/compile/names';
23
import addFormats from 'ajv-formats';
34
import addErrors from 'ajv-errors';
45
import * as ruleSchema from '../meta/rule.schema.json';
@@ -7,8 +8,6 @@ import * as rulesetSchema from '../meta/ruleset.schema.json';
78
import * as jsExtensions from '../meta/js-extensions.json';
89
import * as jsonExtensions from '../meta/json-extensions.json';
910

10-
const message = _`'spectral-message'`;
11-
1211
const validators: { [key in 'js' | 'json']: null | ValidateFunction } = {
1312
js: null,
1413
json: null,
@@ -26,15 +25,16 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction {
2625
strictRequired: false,
2726
keywords: ['$anchor'],
2827
schemas: [ruleSchema, shared],
28+
passContext: true,
2929
});
3030
addFormats(ajv);
3131
addErrors(ajv);
3232
ajv.addKeyword({
3333
keyword: 'spectral-runtime',
3434
schemaType: 'string',
3535
error: {
36-
message(ctx) {
37-
return _`${ctx.data}[Symbol.for(${message})]`;
36+
message(cxt) {
37+
return _`${cxt.params?.message ? cxt.params.message : ''}`;
3838
},
3939
},
4040
code(cxt) {
@@ -44,12 +44,29 @@ export function createValidator(format: 'js' | 'json'): ValidateFunction {
4444
case 'format':
4545
cxt.fail(_`typeof ${data} !== "function"`);
4646
break;
47-
case 'ruleset-function':
48-
cxt.pass(_`typeof ${data}.function === "function"`);
49-
cxt.pass(
50-
_`(() => { try { ${data}.function.validator && ${data}.function.validator('functionOptions' in ${data} ? ${data} : null); } catch (e) { ${data}[${message}] = e.message } })()`,
47+
case 'ruleset-function': {
48+
cxt.gen.if(_`typeof ${data}.function !== "function"`);
49+
cxt.error(false, { message: 'function is not defined' });
50+
cxt.gen.endIf();
51+
const fn = cxt.gen.const(
52+
'spectralFunction',
53+
_`this.validateFunction(${data}.function, ${data}.functionOptions)`,
54+
);
55+
cxt.gen.if(_`${fn} !== void 0`);
56+
cxt.error(false, { message: fn });
57+
cxt.gen.endIf();
58+
break;
59+
}
60+
case 'alias': {
61+
const alias = cxt.gen.const(
62+
'spectralAlias',
63+
_`this.validateAlias(${names.rootData}, ${data}, ${names.instancePath})`,
5164
);
65+
cxt.gen.if(_`${alias} !== void 0`);
66+
cxt.error(false, { message: alias });
67+
cxt.gen.endIf();
5268
break;
69+
}
5370
}
5471
},
5572
});

packages/core/src/ruleset/validation/assertions.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,44 @@
11
import { isPlainObject } from '@stoplight/json';
2+
import { isError } from 'lodash';
23
import { createValidator } from './ajv';
34
import { RulesetAjvValidationError, RulesetValidationError } from './errors';
45
import type { FileRuleDefinition, RuleDefinition, RulesetDefinition } from '../types';
6+
import { resolveAlias } from '../alias';
7+
import { RulesetFunction, RulesetFunctionWithValidator } from '../../types';
8+
9+
function validateAlias(
10+
ruleset: { aliases: Record<string, unknown>; overrides: Record<string, unknown> },
11+
alias: string,
12+
path: string,
13+
): string | void {
14+
try {
15+
const parsedPath = path.slice(1).split('/');
16+
if (parsedPath[0] === 'overrides') {
17+
// todo: merge aliases?
18+
} else {
19+
// todo: get a rule and its formats
20+
resolveAlias(ruleset as any, alias, null);
21+
}
22+
} catch (ex) {
23+
return isError(ex) ? ex.message : 'invalid alias';
24+
}
25+
}
26+
27+
function validateFunction(fn: RulesetFunction | RulesetFunctionWithValidator, opts: unknown): string | void {
28+
if (!('validator' in fn)) return;
29+
30+
try {
31+
const validator: RulesetFunctionWithValidator['validator'] = fn.validator.bind(fn);
32+
validator(opts);
33+
} catch (ex) {
34+
return isError(ex) ? ex.message : 'invalid options';
35+
}
36+
}
37+
38+
const VALIDATORS = {
39+
validateAlias,
40+
validateFunction,
41+
};
542

643
export function assertValidRuleset(
744
ruleset: unknown,
@@ -17,7 +54,7 @@ export function assertValidRuleset(
1754

1855
const validate = createValidator(format);
1956

20-
if (!validate(ruleset)) {
57+
if (!validate.call(VALIDATORS, ruleset)) {
2158
throw new RulesetAjvValidationError(ruleset, validate.errors ?? []);
2259
}
2360
}

0 commit comments

Comments
 (0)