@redocly/openapi-core
Version:
See https://github.com/Redocly/redocly-cli
332 lines (281 loc) • 10.5 kB
text/typescript
import { asserts, runOnKeysSet, runOnValuesSet, Asserts } from './asserts';
import { colorize } from '../../../logger';
import { isRef } from '../../../ref-utils';
import { isTruthy, keysOf, isString } from '../../../utils';
import type { AssertionContext, AssertResult } from '../../../config';
import type { Assertion, AssertionDefinition, AssertionLocators } from '.';
import type {
Oas2Visitor,
Oas3Visitor,
SkipFunctionContext,
VisitFunction,
} from '../../../visitors';
import { UserContext } from 'core/src/walk';
export type OrderDirection = 'asc' | 'desc';
export type OrderOptions = {
direction: OrderDirection;
property: string;
};
export type AssertToApply = {
name: keyof Asserts;
conditions: any;
runsOnKeys: boolean;
runsOnValues: boolean;
};
type RunAssertionParams = {
ctx: AssertionContext;
assert: AssertToApply;
assertionProperty?: string;
};
const assertionMessageTemplates = {
problems: '{{problems}}',
};
function getPredicatesFromLocators(
locators: AssertionLocators
): ((key: string | number) => boolean)[] {
const { filterInParentKeys, filterOutParentKeys, matchParentKeys } = locators;
const keyMatcher = matchParentKeys && regexFromString(matchParentKeys);
const matchKeysPredicate =
keyMatcher && ((key: string | number) => keyMatcher.test(key.toString()));
const filterInPredicate =
Array.isArray(filterInParentKeys) &&
((key: string | number) => filterInParentKeys.includes(key.toString()));
const filterOutPredicate =
Array.isArray(filterOutParentKeys) &&
((key: string | number) => !filterOutParentKeys.includes(key.toString()));
return [matchKeysPredicate, filterInPredicate, filterOutPredicate].filter(isTruthy);
}
export function getAssertsToApply(assertion: AssertionDefinition): AssertToApply[] {
const assertsToApply = keysOf(asserts)
.filter((assertName) => assertion.assertions[assertName] !== undefined)
.map((assertName) => {
return {
name: assertName,
conditions: assertion.assertions[assertName],
runsOnKeys: runOnKeysSet.has(assertName),
runsOnValues: runOnValuesSet.has(assertName),
};
});
const shouldRunOnKeys: AssertToApply | undefined = assertsToApply.find(
(assert: AssertToApply) => assert.runsOnKeys && !assert.runsOnValues
);
const shouldRunOnValues: AssertToApply | undefined = assertsToApply.find(
(assert: AssertToApply) => assert.runsOnValues && !assert.runsOnKeys
);
if (shouldRunOnValues && !assertion.subject.property) {
throw new Error(
`${shouldRunOnValues.name} can't be used on all keys. Please provide a single property`
);
}
if (shouldRunOnKeys && assertion.subject.property) {
throw new Error(
`${shouldRunOnKeys.name} can't be used on a single property. Please use 'property'.`
);
}
return assertsToApply;
}
function getAssertionProperties({ subject }: AssertionDefinition): string[] {
return (Array.isArray(subject.property) ? subject.property : [subject?.property]).filter(
Boolean
) as string[];
}
function applyAssertions(
assertionDefinition: AssertionDefinition,
asserts: AssertToApply[],
ctx: AssertionContext
): AssertResult[] {
const properties = getAssertionProperties(assertionDefinition);
const assertResults: Array<AssertResult[]> = [];
for (const assert of asserts) {
if (properties.length) {
for (const property of properties) {
assertResults.push(
runAssertion({
assert,
ctx,
assertionProperty: property,
})
);
}
} else {
assertResults.push(
runAssertion({
assert,
ctx,
})
);
}
}
return assertResults.flat();
}
export function buildVisitorObject(
assertion: Assertion,
subjectVisitor: VisitFunction<any>
): Oas2Visitor | Oas3Visitor {
const targetVisitorLocatorPredicates = getPredicatesFromLocators(assertion.subject);
const targetVisitorSkipFunction = targetVisitorLocatorPredicates.length
? (_: any, key: string | number) =>
!targetVisitorLocatorPredicates.every((predicate) => predicate(key))
: undefined;
const targetVisitor: Oas2Visitor | Oas3Visitor = {
[assertion.subject.type]: {
enter: subjectVisitor,
...(targetVisitorSkipFunction && { skip: targetVisitorSkipFunction }),
},
};
if (!Array.isArray(assertion.where)) {
return targetVisitor;
}
let currentVisitorLevel: Record<string, any> = {};
const visitor: Record<string, any> = currentVisitorLevel;
const context = assertion.where;
for (let index = 0; index < context.length; index++) {
const assertionDefinitionNode = context[index];
if (!isString(assertionDefinitionNode.subject?.type)) {
throw new Error(
`${assertion.assertionId} -> where -> [${index}]: 'type' (String) is required`
);
}
const locatorPredicates = getPredicatesFromLocators(assertionDefinitionNode.subject);
const assertsToApply = getAssertsToApply(assertionDefinitionNode);
const skipFunction = (node: unknown, key: string | number, ctx: SkipFunctionContext): boolean =>
!locatorPredicates.every((predicate) => predicate(key)) ||
!!applyAssertions(assertionDefinitionNode, assertsToApply, { ...ctx, node }).length;
const nodeVisitor = {
...((locatorPredicates.length || assertsToApply.length) && { skip: skipFunction }),
};
if (
assertionDefinitionNode.subject.type === assertion.subject.type &&
index === context.length - 1
) {
// We have to merge the visitors if the last node inside the `where` is the same as the subject.
targetVisitor[assertion.subject.type] = {
enter: subjectVisitor,
...((nodeVisitor.skip && { skip: nodeVisitor.skip }) ||
(targetVisitorSkipFunction && {
skip: (
node,
key,
ctx // We may have locators defined on assertion level and on where level for the same node type
) => !!(nodeVisitor.skip?.(node, key, ctx) || targetVisitorSkipFunction?.(node, key)),
})),
};
} else {
currentVisitorLevel = currentVisitorLevel[assertionDefinitionNode.subject?.type] =
nodeVisitor;
}
}
currentVisitorLevel[assertion.subject.type] = targetVisitor[assertion.subject.type];
return visitor;
}
export function buildSubjectVisitor(assertId: string, assertion: Assertion): VisitFunction<any> {
return (node: any, ctx: UserContext) => {
const properties = getAssertionProperties(assertion);
const defaultMessage = `${colorize.blue(assertId)} failed because the ${colorize.blue(
assertion.subject.type
)} ${colorize.blue(properties.join(', '))} didn't meet the assertions: ${
assertionMessageTemplates.problems
}`.replace(/ +/g, ' ');
const problems = applyAssertions(assertion, getAssertsToApply(assertion), {
...ctx,
node,
});
if (problems.length) {
for (const problemGroup of groupProblemsByPointer(problems)) {
const message = assertion.message || defaultMessage;
const problemMessage = getProblemsMessage(problemGroup);
ctx.report({
message: message.replace(assertionMessageTemplates.problems, problemMessage),
location: getProblemsLocation(problemGroup) || ctx.location,
forceSeverity: assertion.severity || 'error',
suggest: assertion.suggest || [],
ruleId: assertId,
});
}
}
};
}
function groupProblemsByPointer(problems: AssertResult[]): AssertResult[][] {
const groups: Record<string, AssertResult[]> = {};
for (const problem of problems) {
if (!problem.location) continue;
const pointer = problem.location.pointer;
groups[pointer] = groups[pointer] || [];
groups[pointer].push(problem);
}
return Object.values(groups);
}
function getProblemsLocation(problems: AssertResult[]) {
return problems.length ? problems[0].location : undefined;
}
function getProblemsMessage(problems: AssertResult[]) {
return problems.length === 1
? problems[0].message ?? ''
: problems.map((problem) => `\n- ${problem.message ?? ''}`).join('');
}
export function getIntersectionLength(keys: string[], properties: string[]): number {
const props = new Set(properties);
let count = 0;
for (const key of keys) {
if (props.has(key)) {
count++;
}
}
return count;
}
export function isOrdered(value: any[], options: OrderOptions | OrderDirection): boolean {
const direction = (options as OrderOptions).direction || (options as OrderDirection);
const property = (options as OrderOptions).property;
for (let i = 1; i < value.length; i++) {
let currValue = value[i];
let prevVal = value[i - 1];
if (property) {
const currPropValue = value[i][property];
const prevPropValue = value[i - 1][property];
if (!currPropValue || !prevPropValue) {
return false; // property doesn't exist, so collection is not ordered
}
currValue = currPropValue;
prevVal = prevPropValue;
}
if (typeof currValue === 'string' && typeof prevVal === 'string') {
currValue = currValue.toLowerCase();
prevVal = prevVal.toLowerCase();
}
const result = direction === 'asc' ? currValue >= prevVal : currValue <= prevVal;
if (!result) {
return false;
}
}
return true;
}
export function runAssertion({
assert,
ctx,
assertionProperty,
}: RunAssertionParams): AssertResult[] {
const currentLocation = assert.name === 'ref' ? ctx.rawLocation : ctx.location;
if (assertionProperty) {
const values = isRef(ctx.node[assertionProperty])
? ctx.resolve(ctx.node[assertionProperty])?.node
: ctx.node[assertionProperty];
const rawValues = ctx.rawNode[assertionProperty];
const location = currentLocation.child(assertionProperty);
return asserts[assert.name](values, assert.conditions, {
...ctx,
baseLocation: location,
rawValue: rawValues,
});
} else {
const value = Array.isArray(ctx.node) ? ctx.node : Object.keys(ctx.node);
return asserts[assert.name](value, assert.conditions, {
...ctx,
rawValue: ctx.rawNode,
baseLocation: currentLocation,
});
}
}
export function regexFromString(input: string): RegExp | null {
const matches = input.match(/^\/(.*)\/(.*)|(.*)/);
return matches && new RegExp(matches[1] || matches[3], matches[2]);
}