diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts index 8949917f6..e7bd4eeb6 100644 --- a/src/__tests__/linter.jest.test.ts +++ b/src/__tests__/linter.jest.test.ts @@ -1,14 +1,15 @@ import { normalize } from '@stoplight/path'; import { DiagnosticSeverity } from '@stoplight/types'; +import * as nock from 'nock'; import * as path from 'path'; -import { isOpenApiv3 } from '../formats'; -import { httpAndFileResolver } from '../resolvers/http-and-file'; -import { Spectral } from '../spectral'; +import { isOpenApiv3 } from '../formats'; import { functions } from '../functions'; +import { httpAndFileResolver } from '../resolvers/http-and-file'; import { readRuleset } from '../rulesets'; import { setFunctionContext } from '../rulesets/evaluators'; import oasDocumentSchema from '../rulesets/oas/functions/oasDocumentSchema'; +import { Spectral } from '../spectral'; import { IRuleset, RulesetExceptionCollection } from '../types/ruleset'; const customFunctionOASRuleset = path.join(__dirname, './__fixtures__/custom-functions-oas-ruleset.json'); @@ -24,6 +25,7 @@ describe('Linter', () => { afterEach(() => { jest.restoreAllMocks(); + nock.cleanAll(); }); it('should make use of custom functions', async () => { @@ -73,116 +75,161 @@ describe('Linter', () => { ); }); - it('should expose function-live lifespan cache to custom functions', async () => { - const logSpy = jest.spyOn(global.console, 'log').mockImplementation(Function); + describe('custom functions', () => { + it('should have access to function-live lifespan cache', async () => { + const logSpy = jest.spyOn(global.console, 'log').mockImplementation(Function); - await spectral.setRuleset({ - exceptions: {}, - rules: { - foo: { - given: '$', - then: { - function: 'fn', + await spectral.setRuleset({ + exceptions: {}, + rules: { + foo: { + given: '$', + then: { + function: 'fn', + }, }, - }, - bar: { - given: '$', - then: { - function: 'fn', + bar: { + given: '$', + then: { + function: 'fn', + }, }, }, - }, - functions: { - fn: { - source: null, - name: 'fn', - schema: null, - code: `module.exports = function() { + functions: { + fn: { + source: null, + name: 'fn', + schema: null, + code: `module.exports = function() { console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); }`, + }, }, - }, - }); + }); - await spectral.run({}); + await spectral.run({}); - // verifies whether the 2 subsequent calls passed the same cache instance as the first argument - expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[1][0]); + // verifies whether the 2 subsequent calls passed the same cache instance as the first argument + expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[1][0]); - await spectral.run({}); + await spectral.run({}); - expect(logSpy.mock.calls[2][0]).toBe(logSpy.mock.calls[3][0]); - expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[2][0]); - }); + expect(logSpy.mock.calls[2][0]).toBe(logSpy.mock.calls[3][0]); + expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[2][0]); + }); - it('should expose cache to custom functions that is not shared among them', async () => { - const logSpy = jest.spyOn(global.console, 'log').mockImplementation(Function); + it('should have access to cache that is not shared among them', async () => { + const logSpy = jest.spyOn(global.console, 'log').mockImplementation(Function); - await spectral.setRuleset({ - exceptions: {}, - rules: { - foo: { - given: '$', - then: { - function: 'fn', + await spectral.setRuleset({ + exceptions: {}, + rules: { + foo: { + given: '$', + then: { + function: 'fn', + }, }, - }, - bar: { - given: '$', - then: { - function: 'fn-2', + bar: { + given: '$', + then: { + function: 'fn-2', + }, }, }, - }, - functions: { - fn: { - source: null, - name: 'fn', - schema: null, - code: `module.exports = function() { + functions: { + fn: { + source: null, + name: 'fn', + schema: null, + code: `module.exports = function() { console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); }`, - }, - 'fn-2': { - source: null, - name: 'fn-2', - schema: null, - code: `module.exports = function() { + }, + 'fn-2': { + source: null, + name: 'fn-2', + schema: null, + code: `module.exports = function() { console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); }`, + }, }, - }, - }); + }); - await spectral.run({}); + await spectral.run({}); - // verifies whether the 2 subsequent calls **DID NOT** pass the same cache instance as the first argument - expect(logSpy.mock.calls[0][0]).not.toBe(logSpy.mock.calls[1][0]); + // verifies whether the 2 subsequent calls **DID NOT** pass the same cache instance as the first argument + expect(logSpy.mock.calls[0][0]).not.toBe(logSpy.mock.calls[1][0]); - await spectral.run({}); + await spectral.run({}); - // verifies whether the 2 subsequent calls **DID NOT** pass the same cache instance as the first argument - expect(logSpy.mock.calls[2][0]).not.toBe(logSpy.mock.calls[3][0]); + // verifies whether the 2 subsequent calls **DID NOT** pass the same cache instance as the first argument + expect(logSpy.mock.calls[2][0]).not.toBe(logSpy.mock.calls[3][0]); - // verifies whether the 2 subsequent calls to the same function passe the same cache instance as the first argument - expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[2][0]); - expect(logSpy.mock.calls[1][0]).toBe(logSpy.mock.calls[3][0]); - }); + // verifies whether the 2 subsequent calls to the same function passe the same cache instance as the first argument + expect(logSpy.mock.calls[0][0]).toBe(logSpy.mock.calls[2][0]); + expect(logSpy.mock.calls[1][0]).toBe(logSpy.mock.calls[3][0]); + }); - it('should support require calls', async () => { - await spectral.loadRuleset(customFunctionOASRuleset); - expect( - await spectral.run({ - info: {}, - paths: {}, - }), - ).toEqual([ - expect.objectContaining({ - code: 'has-bar-get-operation', - message: 'Object does not have undefined property', - path: ['paths'], - }), - ]); + it('should support require calls', async () => { + await spectral.loadRuleset(customFunctionOASRuleset); + expect( + await spectral.run({ + info: {}, + paths: {}, + }), + ).toEqual([ + expect.objectContaining({ + code: 'has-bar-get-operation', + message: 'Object does not have undefined property', + path: ['paths'], + }), + ]); + }); + + it('should be able to call any available function', async () => { + await spectral.loadRuleset(customDirectoryFunctionsRuleset); + expect(await spectral.run({ bar: 2 })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'validate-bar', + message: '`bar` property should be a string', + }), + ]), + ); + }); + + it('should be able to make a request using fetch', async () => { + const scope = nock('https://stoplight.io') + .get('/') + .once() + .reply(200); + + spectral.setRuleset({ + exceptions: {}, + functions: { + fn: { + source: null, + schema: null, + name: 'fn', + code: `module.exports = () => void fetch('https://stoplight.io')`, + }, + }, + rules: { + empty: { + given: '$', + then: { + function: 'fn', + }, + }, + }, + }); + + await spectral.run({}); + + expect(scope.isDone()).toBe(true); + }); }); it('should respect the scope of defined functions (ruleset-based)', async () => { @@ -199,18 +246,6 @@ console.log(this.cache.get('test') || this.cache.set('test', []).get('test')); ]); }); - it('should expose all available functions to custom functions', async () => { - await spectral.loadRuleset(customDirectoryFunctionsRuleset); - expect(await spectral.run({ bar: 2 })).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'validate-bar', - message: '`bar` property should be a string', - }), - ]), - ); - }); - it('should report resolving errors for correct files', async () => { spectral = new Spectral({ resolver: httpAndFileResolver }); diff --git a/src/__tests__/linter.karma.test.ts b/src/__tests__/linter.karma.test.ts new file mode 100644 index 000000000..89d5101c4 --- /dev/null +++ b/src/__tests__/linter.karma.test.ts @@ -0,0 +1,49 @@ +import { FetchMockSandbox } from 'fetch-mock'; +import { Spectral } from '../spectral'; + +describe('Linter', () => { + let fetchMock: FetchMockSandbox; + let spectral: Spectral; + + beforeEach(() => { + fetchMock = require('fetch-mock').sandbox(); + window.fetch = fetchMock; + spectral = new Spectral(); + }); + + afterEach(() => { + window.fetch = fetch; + }); + + describe('custom functions', () => { + it('should be able to make a request using fetch', async () => { + fetchMock.mock('https://stoplight.io', { + status: 200, + }); + + spectral.setRuleset({ + exceptions: {}, + functions: { + fn: { + source: null, + schema: null, + name: 'fn', + code: `module.exports = () => void fetch('https://stoplight.io')`, + }, + }, + rules: { + empty: { + given: '$', + then: { + function: 'fn', + }, + }, + }, + }); + + await spectral.run({}); + + expect(fetchMock.calls('https://stoplight.io')).toHaveLength(1); + }); + }); +}); diff --git a/src/rulesets/__tests__/evaluators.test.ts b/src/rulesets/__tests__/evaluators.test.ts index 32bde66df..c420ef06a 100644 --- a/src/rulesets/__tests__/evaluators.test.ts +++ b/src/rulesets/__tests__/evaluators.test.ts @@ -59,6 +59,28 @@ describe('Code evaluators', () => { expect(() => evaluateExport(`module.exports = 2`, null)).toThrow(); expect(() => evaluateExport(`this.returnExports = {}`, null)).toThrow(); }); + + describe('inject', () => { + it('can expose any arbitrary value', () => { + const fetch = jest.fn(); + const url = 'https://foo.bar'; + const fn = evaluateExport(`module.exports = () => fetch(url, { headers })`, null, { + fetch, + url, + headers: { + auth: 'Basic bar', + }, + }); + + fn(); + + expect(fetch).toBeCalledWith(url, { + headers: { + auth: 'Basic bar', + }, + }); + }); + }); }); describe('setFunctionContext', () => { diff --git a/src/rulesets/evaluators.ts b/src/rulesets/evaluators.ts index c31ddbc07..56ccc3baa 100644 --- a/src/rulesets/evaluators.ts +++ b/src/rulesets/evaluators.ts @@ -101,7 +101,7 @@ const isESCJSCompatibleExport = (obj: unknown): obj is ESCJSCompatibleExport => // note: this code is hand-crafted and cover cases we want to support // be aware of using it in your own project if you need to support a variety of module systems -export const evaluateExport = (body: string, source: string | null): Function => { +export const evaluateExport = (body: string, source: string | null, inject: Dictionary = {}): Function => { const req = createRequire(source); const mod: CJSExport = { exports: {}, @@ -111,7 +111,14 @@ export const evaluateExport = (body: string, source: string | null): Function => const root: ContextExport = {}; const define = createDefine(mod); - Function('module, exports, define, require', String(body)).call(root, mod, exports, define, req); + Function('module', 'exports', 'define', 'require', ...Object.keys(inject), String(body)).call( + root, + mod, + exports, + define, + req, + ...Object.values(inject), + ); let maybeFn: unknown; @@ -132,13 +139,16 @@ export const evaluateExport = (body: string, source: string | null): Function => return maybeFn; }; -export const compileExportedFunction = ( - code: string, - name: string, - source: string | null, - schema: JSONSchema | null, -) => { - const exportedFn = evaluateExport(code, source) as IFunction; +export type CompileOptions = { + code: string; + name: string; + source: string | null; + schema: JSONSchema | null; + inject: Dictionary; +}; + +export const compileExportedFunction = ({ code, name, source, schema, inject }: CompileOptions) => { + const exportedFn = evaluateExport(code, source, inject) as IFunction; const fn = schema !== null ? decorateIFunctionWithSchemaValidation(exportedFn, schema) : exportedFn; diff --git a/src/spectral.ts b/src/spectral.ts index ebcd71b5f..f91b70d7b 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -9,6 +9,7 @@ import { Document, IDocument, IParsedResult, isParsedResult, ParsedDocument } fr import { DocumentInventory } from './documentInventory'; import { CoreFunctions, functions as coreFunctions } from './functions'; import * as Parsers from './parsers'; +import request from './request'; import { readRuleset } from './rulesets'; import { compileExportedFunction, setFunctionContext } from './rulesets/evaluators'; import { mergeExceptions } from './rulesets/mergers/exceptions'; @@ -174,7 +175,18 @@ export class Spectral { cache: new Map(), }; - fns[key] = setFunctionContext(context, compileExportedFunction(code, name, source, schema)); + fns[key] = setFunctionContext( + context, + compileExportedFunction({ + code, + name, + source, + schema, + inject: { + fetch: request, + }, + }), + ); return fns; }, {