Skip to content

Commit

Permalink
feat(core): support scoped aliases (#1840)
Browse files Browse the repository at this point in the history
  • Loading branch information
P0lip committed Oct 11, 2021
1 parent 51b6440 commit b278497
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 52 deletions.
5 changes: 5 additions & 0 deletions packages/core/src/guards/isAggregateError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { isError } from 'lodash';

export function isAggregateError(maybeAggregateError: unknown): maybeAggregateError is Error & { errors: unknown[] } {
return isError(maybeAggregateError) && maybeAggregateError.constructor.name === 'AggregateError';
}
183 changes: 173 additions & 10 deletions packages/core/src/ruleset/__tests__/ruleset.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { oas2 } from '@stoplight/spectral-formats';
import { truthy } from '@stoplight/spectral-functions';
import { pattern, truthy } from '@stoplight/spectral-functions';
import * as path from '@stoplight/path';
import { DiagnosticSeverity } from '@stoplight/types';

import { Ruleset } from '../ruleset';
import { RulesetDefinition } from '../types';
import { print } from './__helpers__/print';
import { RulesetValidationError } from '../validation';
import { isPlainObject } from '@stoplight/json';
import { Format } from '../format';
import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
import { FormatsSet } from '../utils/formatsSet';

async function loadRuleset(mod: Promise<{ default: RulesetDefinition }>, source?: string): Promise<Ruleset> {
return new Ruleset((await mod).default, { source });
Expand Down Expand Up @@ -1233,8 +1237,7 @@ describe('Ruleset', () => {

it('given unresolved alias, should throw', () => {
expect(
print.bind(
null,
() =>
new Ruleset({
extends: {
aliases: {
Expand All @@ -1251,14 +1254,12 @@ describe('Ruleset', () => {
},
},
}),
),
).toThrowError(ReferenceError('Alias "PathItem-" does not exist'));
});

it('given circular alias, should throw', () => {
expect(
print.bind(
null,
() =>
new Ruleset({
aliases: {
Root: '#Info',
Expand All @@ -1275,16 +1276,14 @@ describe('Ruleset', () => {
},
},
}),
),
).toThrowError(
ReferenceError('Alias "Test" is circular. Resolution stack: Test -> Contact -> Info -> Root -> Info'),
);
});

it('should refuse to resolve externally defined aliases', () => {
expect(
print.bind(
null,
() =>
new Ruleset({
extends: {
aliases: {
Expand All @@ -1310,8 +1309,172 @@ describe('Ruleset', () => {
},
},
}),
),
).toThrowError(ReferenceError('Alias "PathItem" does not exist'));
});

describe('scoped aliases', () => {
it('should resolve locally defined aliases according to their targets', () => {
const draft4: Format<JSONSchema4> = (input): input is JSONSchema4 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-04/schema#';
const draft6: Format<JSONSchema6> = (input): input is JSONSchema6 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-06/schema#';
const draft7: Format<JSONSchema7> = (input): input is JSONSchema7 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-07/schema#';

const ruleset = new Ruleset({
aliases: {
Id: {
targets: [
{
formats: [draft4],
given: '$..id',
},
{
formats: [draft6, draft7],
given: '$..$id',
},
],
},
},
rules: {
'valid-id': {
given: '#Id',
then: {
function: pattern,
functionOptions: {
match: '^project_',
},
},
},
},
});

expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft4]))).toStrictEqual(['$..id']);
expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft6]))).toStrictEqual(['$..$id']);
expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft7]))).toStrictEqual(['$..$id']);
expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft6, draft7]))).toStrictEqual([
'$..$id',
]);
});

it('should drop aliases not matching any target', () => {
const draft6: Format<JSONSchema6> = (input): input is JSONSchema6 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-06/schema#';
const draft7: Format<JSONSchema7> = (input): input is JSONSchema7 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-07/schema#';

const ruleset = new Ruleset({
aliases: {
Id: {
targets: [
{
formats: [draft6],
given: '$..$id',
},
],
},
},
rules: {
'valid-id': {
given: '#Id',
then: {
function: pattern,
functionOptions: {
match: '^project_',
},
},
},
},
});

expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([draft7]))).toStrictEqual([]);
expect(ruleset.rules['valid-id'].getGivenForFormats(new FormatsSet([]))).toStrictEqual([]);
});

it('should be serializable', () => {
const draft4: Format<JSONSchema4> = (input): input is JSONSchema4 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-04/schema#';
const draft6: Format<JSONSchema6> = (input): input is JSONSchema6 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-06/schema#';
const draft7: Format<JSONSchema7> = (input): input is JSONSchema7 =>
isPlainObject(input) && input.$schema === 'http://json-schema.org/draft-07/schema#';

expect(
JSON.parse(
JSON.stringify(
new Ruleset({
aliases: {
Id: {
targets: [
{
formats: [draft4],
given: '$..id',
},
{
formats: [draft6, draft7],
given: '$..$id',
},
],
},
},

rules: {
'valid-id': {
given: '#Id',
then: {
function: truthy,
},
},
},
}),
),
),
).toEqual({
extends: null,
source: null,
id: expect.any(Number),
formats: null,
overrides: null,
parserOptions: {
duplicateKeys: DiagnosticSeverity.Error,
incompatibleValues: DiagnosticSeverity.Error,
},
aliases: {
Id: {
targets: [
{
formats: ['draft4'],
given: '$..id',
},
{
formats: ['draft6', 'draft7'],
given: '$..$id',
},
],
},
},
rules: {
'valid-id': {
description: null,
documentationUrl: null,
enabled: true,
formats: null,
given: ['#Id'],
message: null,
name: 'valid-id',
owner: expect.any(Number),
recommended: true,
resolved: true,
severity: DiagnosticSeverity.Warning,
then: [
{
function: 'truthy',
},
],
},
},
});
});
});
});
});
76 changes: 60 additions & 16 deletions packages/core/src/ruleset/rule/rule.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { JsonPath, Optional } from '@stoplight/types';
import { isString } from 'lodash';
import { JsonPath, Optional, DiagnosticSeverity } from '@stoplight/types';
import { dirname, relative } from '@stoplight/path';
import { DiagnosticSeverity } from '@stoplight/types';
import { pathToPointer } from '@stoplight/json';
import { printValue } from '@stoplight/spectral-runtime';

import { getDiagnosticSeverity, DEFAULT_SEVERITY_LEVEL } from '../utils/severity';
import { Ruleset } from '../ruleset';
import { Format } from '../format';
import { HumanReadableDiagnosticSeverity, IRuleThen, RuleDefinition } from '../types';
import type {
HumanReadableDiagnosticSeverity,
IRuleThen,
RuleDefinition,
RulesetScopedAliasDefinition,
} from '../types';
import { minimatch } from '../utils/minimatch';
import { printValue } from '@stoplight/spectral-runtime';
import { FormatsSet } from '../utils/formatsSet';

const ALIAS = /^#([A-Za-z0-9_-]+)/;

Expand All @@ -27,7 +33,7 @@ export interface IRule {

export type StringifiedRule = Omit<IRule, 'formats' | 'then'> & {
name: string;
formats: string[] | null;
formats: FormatsSet | null;
then: (Pick<IRuleThen, 'field'> & { function: string; functionOptions?: string })[];
owner: number;
};
Expand All @@ -37,7 +43,7 @@ export class Rule implements IRule {
public message: string | null;
#severity!: DiagnosticSeverity;
public resolved: boolean;
public formats: Set<Format> | null;
public formats: FormatsSet | null;
#enabled: boolean;
public recommended: boolean;
public documentationUrl: string | null;
Expand All @@ -56,7 +62,7 @@ export class Rule implements IRule {
this.documentationUrl = definition.documentationUrl ?? null;
this.severity = definition.severity;
this.resolved = definition.resolved !== false;
this.formats = 'formats' in definition ? new Set(definition.formats) : null;
this.formats = 'formats' in definition ? new FormatsSet(definition.formats) : null;
this.then = definition.then;
this.given = definition.given;
}
Expand Down Expand Up @@ -126,15 +132,23 @@ export class Rule implements IRule {
}

public get given(): string[] {
// eslint-disable-next-line @typescript-eslint/unbound-method
return this.#given.map(this.#resolveAlias, this);
return this.#given;
}

public set given(given: RuleDefinition['given']) {
this.#given = Array.isArray(given) ? given : [given];
const actualGiven = Array.isArray(given) ? given : [given];
this.#given = this.owner.hasComplexAliases
? actualGiven
: actualGiven.map(expr => this.#resolveAlias(expr, null)).filter(isString);
}

#resolveAlias(this: Rule, expr: string): string {
public getGivenForFormats(formats: Set<Format<any>> | null): string[] {
return this.owner.hasComplexAliases
? this.#given.map(expr => this.#resolveAlias(expr, formats)).filter(isString)
: this.#given;
}

#resolveAlias(expr: string, formats: Set<Format> | null): string | null {
let resolvedExpr = expr;

const stack = new Set<string>();
Expand All @@ -157,16 +171,46 @@ export class Rule implements IRule {
throw new ReferenceError(`Alias "${alias}" does not exist`);
}

if (alias.length + 1 === expr.length) {
resolvedExpr = this.owner.aliases[alias];
const aliasValue = this.owner.aliases[alias];
let actualAliasValue;
if (typeof aliasValue === 'string') {
actualAliasValue = aliasValue;
} else {
actualAliasValue = this.#resolveAliasForFormats(aliasValue, formats);
}

if (actualAliasValue === null) {
return null;
}

if (actualAliasValue.length + 1 === expr.length) {
resolvedExpr = actualAliasValue;
} else {
resolvedExpr = this.owner.aliases[alias] + resolvedExpr.slice(alias.length + 1);
resolvedExpr = actualAliasValue + resolvedExpr.slice(alias.length + 1);
}
}

return resolvedExpr;
}

#resolveAliasForFormats({ targets }: RulesetScopedAliasDefinition, formats: Set<Format> | 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<Format> | null): boolean {
if (this.formats === null) {
return true;
Expand Down Expand Up @@ -199,13 +243,13 @@ export class Rule implements IRule {
documentationUrl: this.documentationUrl,
severity: this.severity,
resolved: this.resolved,
formats: this.formats === null ? null : Array.from(this.formats).map(fn => fn.displayName ?? fn.name),
formats: this.formats,
then: this.then.map(then => ({
...then.function,
function: then.function.name,
...('functionOptions' in then ? { functionOptions: printValue(then.functionOptions) } : null),
})),
given: this.#given,
given: Array.isArray(this.definition.given) ? this.definition.given : [this.definition.given],
owner: this.owner.id,
};
}
Expand Down

0 comments on commit b278497

Please sign in to comment.