Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(core): revise RulesetFunction #1685

Merged
merged 1 commit into from
Jun 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions docs/guides/5-custom-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,21 @@ If the message was goodbye, we'd have a problem.

## Writing Functions

A custom function might be any JavaScript function compliant with [IFunction](https://github.com/stoplightio/spectral/blob/90a0864863fa232bf367a26dace61fd9f93198db/src/types/function.ts#L3#L8) type.
A custom function might be any JavaScript function compliant with `RulesetFunction` type.

```ts
export type IFunction<O = any> = (targetValue: any, options: O, paths: IFunctionPaths, otherValues: IFunctionValues) => void | IFunctionResult[];
export type RulesetFunction<I extends unknown = unknown, O extends unknown = unknown> = (
input: I,
options: O,
context: RulesetFunctionContext,
) => void | IFunctionResult[] | Promise<void | IFunctionResult[]>;

export type RulesetFunctionContext = {
path: JsonPath;
document: IDocument;
documentInventory: IDocumentInventory;
rule: IRule;
};
```

### Validating options
Expand Down Expand Up @@ -93,7 +104,7 @@ module.exports = (targetVal, opts) => {

### targetValue

`targetValue` the value the custom function is provided with and is supposed to lint against.
`input` the value the custom function is provided with and is supposed to lint against.

It's based on `given` [JSON Path][jsonpath] expression defined on the rule and optionally `field` if placed on `then`.

Expand Down Expand Up @@ -122,20 +133,17 @@ operation-id-kebab-case:
match: ^[a-z][a-z0-9\-]*$
```

### paths

`paths.given` contains [JSON Path][jsonpath] expression you set in a rule - in `given` field.

If a particular rule has a `field` property in `then`, that path will be exposed as `paths.target`.
### context

### otherValues
`context.path` contains a resolved property path pointing to the place in the document

`otherValues.original` and `otherValues.given` are equal for the most of time and represent the value matched using JSON Path expression.
`context.document` provides an access to the document that we attempt to lint.
You may find it useful if you'd like to see which formats were applied to it, or in case you'd like to get its unresolved version.

`otherValues.documentInventory` provides an access to resolved and unresolved documents as well as some other advanced properties.
You shouldn't need it for most of the time. For the list of available options, please refer to the [source code](../../src/documentInventory.ts).
`context.documentInventory` provides an access to resolved and unresolved documents as well, $ref resolution graph, as some other advanced properties.
You shouldn't need it for most of the time.

`otherValues.rule` an actual rule your function was called for.
`context.rule` an actual rule your function was called for.

Custom functions take exactly the same arguments as core functions do, so you are more than welcome to take a look at the existing implementation.

Expand Down
84 changes: 1 addition & 83 deletions packages/core/src/__tests__/linter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { falsy, pattern, truthy } from '@stoplight/spectral-functions';
import { DiagnosticSeverity, JsonPath } from '@stoplight/types';
import { DiagnosticSeverity } from '@stoplight/types';
import { parse } from '@stoplight/yaml';
import * as Parsers from '@stoplight/spectral-parsers';
import { Resolver } from '@stoplight/spectral-ref-resolver';
Expand Down Expand Up @@ -818,88 +818,6 @@ responses:: !!foo
);
});

describe('functional tests for the given property', () => {
let fakeLintingFunction: jest.Mock;
const rules = {
example: {
message: '',
given: '$.responses',
then: {
get function() {
return fakeLintingFunction;
},
},
},
};

beforeEach(() => {
fakeLintingFunction = jest.fn();
spectral.setRuleset({
rules,
});
});

describe('when given path is set', () => {
test('should pass given path through to lint function', async () => {
let path: JsonPath | null = null;
let given: unknown;
fakeLintingFunction.mockImplementation((_targetVal, _opts, paths, values) => {
path = [...paths.given];
given = values.given;
});

await spectral.run(target);

expect(fakeLintingFunction).toHaveBeenCalledTimes(1);
expect(path).toEqual(['responses']);
expect(given).toEqual(target.responses);
});

test('given array of paths, should pass each given path through to lint function', async () => {
spectral.setRuleset({
rules: {
example: {
message: '',
given: ['$.responses', '$..200'],
then: {
function: fakeLintingFunction,
},
},
},
});

await spectral.run(target);

expect(fakeLintingFunction).toHaveBeenCalledTimes(2);
expect(fakeLintingFunction.mock.calls[0][2].given).toEqual(['responses']);
expect(fakeLintingFunction.mock.calls[0][3].given).toEqual(target.responses);
expect(fakeLintingFunction.mock.calls[1][2].given).toEqual(['responses', '200']);
expect(fakeLintingFunction.mock.calls[1][3].given).toEqual(target.responses['200']);
});
});

describe('when given path is not set', () => {
test('should pass through root object', async () => {
spectral.setRuleset({
rules: {
example: {
message: '',
given: '$',
then: {
function: fakeLintingFunction,
},
},
},
});
await spectral.run(target);

expect(fakeLintingFunction).toHaveBeenCalledTimes(1);
expect(fakeLintingFunction.mock.calls[0][2].given).toEqual([]);
expect(fakeLintingFunction.mock.calls[0][3].given).toEqual(target);
});
});
});

describe('functional tests for the then statement', () => {
let fakeLintingFunction: jest.Mock;
let fakeLintingFunction2: jest.Mock;
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/documentInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,21 @@ export type DocumentInventoryItem = {
missingPropertyPath: JsonPath;
};

export class DocumentInventory {
private static readonly _cachedRemoteDocuments = new WeakMap<Resolver['uriCache'], Dictionary<Document>>();
export interface IDocumentInventory {
readonly graph: ResolveResult['graph'] | null;
readonly referencedDocuments: Dictionary<IDocument>;
findAssociatedItemForPath(path: JsonPath, resolved: boolean): DocumentInventoryItem | null;
}

export class DocumentInventory implements IDocumentInventory {
private static readonly _cachedRemoteDocuments = new WeakMap<Resolver['uriCache'], Dictionary<IDocument>>();

public graph: ResolveResult['graph'] | null;
public resolved: unknown;
public errors: IRuleResult[] | null;
public diagnostics: IRuleResult[] = [];

public readonly referencedDocuments: Dictionary<Document>;
public readonly referencedDocuments: Dictionary<IDocument>;

public get source(): string | null {
return this.document.source;
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/ruleset/rule/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,20 @@ import { Ruleset } from '../ruleset';
import { Format } from '../format';
import { HumanReadableDiagnosticSeverity, IRuleThen, RuleDefinition } from '../types';

export class Rule {
export interface IRule {
description: string | null;
message: string | null;
severity: DiagnosticSeverity;
resolved: boolean;
formats: Set<Format> | null;
enabled: boolean;
recommended: boolean;
documentationUrl: string | null;
then: IRuleThen[];
given: string[];
}

export class Rule implements IRule {
public description: string | null;
public message: string | null;
#severity!: DiagnosticSeverity;
Expand Down
27 changes: 11 additions & 16 deletions packages/core/src/runner/lintNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { decodeSegmentFragment, getClosestJsonPath, printPath, PrintStyle } from
import { get } from 'lodash';

import { Document } from '../document';
import { IFunctionResult, IFunctionValues, IGivenNode } from '../types';
import { IFunctionResult, IGivenNode, RulesetFunctionContext } from '../types';
import { IRunnerInternalContext } from './types';
import { getLintTargets, MessageVars, message } from './utils';
import { Rule } from '../ruleset/rule/rule';

export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule: Rule): void => {
const fnContext: IFunctionValues = {
original: node.value,
given: node.value,
const fnContext: RulesetFunctionContext = {
document: context.documentInventory.document,
documentInventory: context.documentInventory,
rule,
path: [],
};

const givenPath = node.path.length > 0 && node.path[0] === '$' ? node.path.slice(1) : node.path;
Expand All @@ -22,17 +22,12 @@ export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule
const targets = getLintTargets(node.value, then.field);

for (const target of targets) {
const targetPath = target.path.length > 0 ? [...givenPath, ...target.path] : givenPath;
const path = target.path.length > 0 ? [...givenPath, ...target.path] : givenPath;

const targetResults = then.function(
target.value,
then.functionOptions ?? null,
{
given: givenPath,
target: targetPath,
},
fnContext,
);
const targetResults = then.function(target.value, then.functionOptions ?? null, {
...fnContext,
path,
});

if (targetResults === void 0) continue;

Expand All @@ -45,7 +40,7 @@ export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule
context,
results,
rule,
targetPath, // todo: get rid of it somehow.
path, // todo: get rid of it somehow.
),
),
);
Expand All @@ -54,7 +49,7 @@ export const lintNode = (context: IRunnerInternalContext, node: IGivenNode, rule
context,
targetResults,
rule,
targetPath, // todo: get rid of it somehow.
path, // todo: get rid of it somehow.
);
}
}
Expand Down
27 changes: 11 additions & 16 deletions packages/core/src/types/function.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { JsonPath } from '@stoplight/types';
import { DocumentInventory } from '../documentInventory';
import type { Rule } from '../ruleset/rule/rule';
import type { IDocumentInventory } from '../documentInventory';
import type { IRule } from '../ruleset/rule/rule';
import type { IDocument } from '../document';

export type RulesetFunction<I extends unknown = unknown, O extends unknown = unknown> = (
input: I,
options: O,
paths: IFunctionPaths,
otherValues: IFunctionValues,
context: RulesetFunctionContext,
) => void | IFunctionResult[] | Promise<void | IFunctionResult[]>;

export type RulesetFunctionContext = {
path: JsonPath;
document: IDocument;
documentInventory: IDocumentInventory;
rule: IRule;
};

export type IFunction = RulesetFunction;

export type RulesetFunctionWithValidator<I extends unknown = unknown, O extends unknown = unknown> = RulesetFunction<
Expand All @@ -18,18 +25,6 @@ export type RulesetFunctionWithValidator<I extends unknown = unknown, O extends
validator<O = unknown>(options: unknown): asserts options is O;
};

export interface IFunctionPaths {
given: JsonPath;
target?: JsonPath;
}

export interface IFunctionValues {
original: unknown;
given: unknown;
documentInventory: DocumentInventory;
rule: Rule;
}

export interface IFunctionResult {
message: string;
path?: JsonPath;
Expand Down
8 changes: 3 additions & 5 deletions packages/functions/src/alphabetical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,15 @@ export default createRulesetFunction<Record<string, unknown> | unknown[], Option
},
},
},
function alphabetical(targetVal, opts, paths, { documentInventory }) {
function alphabetical(targetVal, opts, { path, documentInventory }) {
let targetArray: unknown[];

if (Array.isArray(targetVal)) {
targetArray = targetVal;
} else {
targetArray = Object.keys(
documentInventory
.findAssociatedItemForPath(paths.given, true)
?.document.trapAccess<typeof targetVal>(targetVal) ?? targetVal,
documentInventory.findAssociatedItemForPath(path, true)?.document.trapAccess<typeof targetVal>(targetVal) ??
targetVal,
);
}

Expand Down Expand Up @@ -102,7 +101,6 @@ export default createRulesetFunction<Record<string, unknown> | unknown[], Option
const unsortedItems = getUnsortedItems(targetArray, compare);

if (unsortedItems != null) {
const path = paths.target ?? paths.given;
return [
{
...(keyedBy === void 0
Expand Down
4 changes: 1 addition & 3 deletions packages/functions/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ export default createRulesetFunction<unknown, Options>(
},
},
},
function schema(targetVal, opts, paths, { rule }) {
const path = paths.target ?? paths.given;

function schema(targetVal, opts, { path, rule }) {
if (targetVal === void 0) {
return [
{
Expand Down
6 changes: 3 additions & 3 deletions packages/functions/src/unreferencedReusableObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ export default createRulesetFunction<Record<string, unknown>, Options>(
},
},
},
function unreferencedReusableObject(data, opts, _paths, otherValues) {
const graph = otherValues.documentInventory.graph;
function unreferencedReusableObject(data, opts, { document, documentInventory }) {
const graph = documentInventory.graph;
if (graph === null) {
throw new Error('unreferencedReusableObject requires dependency graph');
}

const normalizedSource = otherValues.documentInventory.source ?? '';
const normalizedSource = document.source ?? '';

const defined = Object.keys(data).map(name => `${normalizedSource}${opts.reusableObjectsLocation}/${name}`);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import asyncApi2PayloadValidation from '../asyncApi2PayloadValidation';

function runPayloadValidation(targetVal: any) {
return asyncApi2PayloadValidation(
targetVal,
null,
{ given: ['$', 'components', 'messages', 'aMessage'] },
{ given: null, original: null, documentInventory: {} as any, rule: {} as any },
);
return asyncApi2PayloadValidation(targetVal, null, { path: ['components', 'messages', 'aMessage'] } as any);
}

describe('asyncApi2PayloadValidation', () => {
Expand All @@ -21,7 +16,7 @@ describe('asyncApi2PayloadValidation', () => {
expect(results).toEqual([
{
message: '`deprecated` property type must be boolean',
path: ['$', 'components', 'messages', 'aMessage', 'deprecated'],
path: ['components', 'messages', 'aMessage', 'deprecated'],
},
]);
});
Expand Down
Loading