Skip to content

Commit

Permalink
feat(ruleset): allow require calls in Node.JS
Browse files Browse the repository at this point in the history
  • Loading branch information
P0lip committed Mar 12, 2020
1 parent daa99e6 commit c4f7d23
Show file tree
Hide file tree
Showing 8 changed files with 107 additions and 19 deletions.
20 changes: 10 additions & 10 deletions src/rulesets/__tests__/evaluators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,61 @@ import { evaluateExport } from '../evaluators';
describe('Code evaluators', () => {
describe('Export evaluator', () => {
it('detects CJS default export', () => {
const exported = evaluateExport(`module.exports = function a(x, y) {}`);
const exported = evaluateExport(`module.exports = function a(x, y) {}`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'a');
expect(exported).toHaveProperty('length', 2);
});

it('detects CJS-ES compatible default export', () => {
const exported = evaluateExport(`exports.default = function b(x, y) {}`);
const exported = evaluateExport(`exports.default = function b(x, y) {}`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'b');
expect(exported).toHaveProperty('length', 2);
});

it('detects CJS-ES compatible default export variant #2', () => {
const exported = evaluateExport(`module.exports.default = function c(x, y, z) {}`);
const exported = evaluateExport(`module.exports.default = function c(x, y, z) {}`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'c');
expect(exported).toHaveProperty('length', 3);
});

it('detects AMD export', () => {
const exported = evaluateExport(`define(['exports'], () => function d(x){} )`);
const exported = evaluateExport(`define(['exports'], () => function d(x){} )`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'd');
expect(exported).toHaveProperty('length', 1);
});

it('detects anonymous AMD export', () => {
const exported = evaluateExport(`define(() => function d(x){} )`);
const exported = evaluateExport(`define(() => function d(x){} )`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'd');
expect(exported).toHaveProperty('length', 1);
});

it('detects context-based export', () => {
const exported = evaluateExport(`this.returnExports = function e() {}`);
const exported = evaluateExport(`this.returnExports = function e() {}`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'e');
expect(exported).toHaveProperty('length', 0);
});

it('detects context-based export', () => {
const exported = evaluateExport(`this.returnExports = function e() {}`);
const exported = evaluateExport(`this.returnExports = function e() {}`, null);
expect(exported).toBeInstanceOf(Function);
expect(exported).toHaveProperty('name', 'e');
expect(exported).toHaveProperty('length', 0);
});

it('throws error if no default export can be found', () => {
expect(() => evaluateExport(`exports.a = function b(x, y) {}`)).toThrow();
expect(() => evaluateExport(`exports.a = function b(x, y) {}`, null)).toThrow();
});

it('throws error default export is not a function', () => {
expect(() => evaluateExport(`module.exports = 2`)).toThrow();
expect(() => evaluateExport(`this.returnExports = {}`)).toThrow();
expect(() => evaluateExport(`module.exports = 2`, null)).toThrow();
expect(() => evaluateExport(`this.returnExports = {}`, null)).toThrow();
});
});
});
6 changes: 6 additions & 0 deletions src/rulesets/__tests__/reader.jest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,11 +563,13 @@ describe('Rulesets reader', () => {
name: 'foo.cjs',
ref: 'random-id-0',
schema: null,
source: path.join(fooRuleset, '../functions/foo.cjs.js'),
},
'random-id-0': {
name: 'foo.cjs',
code: fooCJSFunction,
schema: null,
source: path.join(fooRuleset, '../functions/foo.cjs.js'),
},
});

Expand All @@ -592,11 +594,13 @@ describe('Rulesets reader', () => {
name: 'bar',
ref: expect.stringMatching(/^random-id-[01]$/),
schema: null,
source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/bar.js'),
},
truthy: {
name: 'truthy',
ref: expect.stringMatching(/^random-id-[01]$/),
schema: null,
source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/truthy.js'),
},
}),
);
Expand All @@ -613,12 +617,14 @@ describe('Rulesets reader', () => {
name: 'bar',
code: barFunction,
schema: null,
source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/bar.js'),
});

expect(truthyFunctionDef).toEqual({
name: 'truthy',
code: truthyFunction,
schema: null,
source: path.join(customFunctionsDirectoryRuleset, '../customFunctions/truthy.js'),
});

expect(ruleset.functions.bar).toHaveProperty('name', 'bar');
Expand Down
79 changes: 73 additions & 6 deletions src/rulesets/evaluators.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,72 @@
import { Optional } from '@stoplight/types';
import { isAbsolute, join, stripRoot } from '@stoplight/path';
import { Dictionary, Optional } from '@stoplight/types';
import { isObject } from 'lodash';
import { IFunction, JSONSchema } from '../types';
import { decorateIFunctionWithSchemaValidation } from './validation';

export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport }>;
export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport; require: NodeJS.Require }>;
export type ESCJSCompatibleExport = Partial<{ default: unknown }>;
export type ContextExport = Partial<{ returnExports: unknown }>;

function requireUnavailable() {
throw new ReferenceError('require() is supported only in the Node.JS environment');
}

function stubRequire(): NodeJS.Require {
function req() {
requireUnavailable();
}

const descriptors: Dictionary<PropertyDescriptor, keyof NodeJS.Require> = {
resolve: {
enumerable: true,
get: requireUnavailable,
},

main: {
enumerable: true,
get: requireUnavailable,
},

cache: {
enumerable: true,
get: requireUnavailable,
},

extensions: {
enumerable: true,
get: requireUnavailable,
},
};

return Object.defineProperties(req, descriptors);
}

function proxyRequire(source: string): NodeJS.Require {
const actualRequire = require;
function req(p: string) {
if (!isAbsolute(p)) {
p = join(source, '..', stripRoot(p));
}

return actualRequire.call(null, p);
}

return Object.defineProperties(req, Object.getOwnPropertyDescriptors(actualRequire));
}

const createRequire = (source: string | null): NodeJS.Require => {
if (typeof require === 'undefined') {
return stubRequire();
}

if (source === null) {
return require;
}

return proxyRequire(source);
};

const createDefine = (exports: CJSExport) => {
const define = (nameOrFactory: string | string[] | Function, factory: Function): Optional<CJSExport> => {
if (typeof nameOrFactory === 'function') {
Expand All @@ -32,15 +92,17 @@ 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): Function => {
export const evaluateExport = (body: string, source: string | null): Function => {
const req = createRequire(source);
const mod: CJSExport = {
exports: {},
require: req,
};
const exports: ESCJSCompatibleExport | unknown = {};
const root: ContextExport = {};
const define = createDefine(mod);

Function('module, exports, define', String(body)).call(root, mod, exports, define);
Function('module, exports, define, require', String(body)).call(root, mod, exports, define, req);

let maybeFn: unknown;

Expand All @@ -61,8 +123,13 @@ export const evaluateExport = (body: string): Function => {
return maybeFn;
};

export const compileExportedFunction = (code: string, name: string, schema: JSONSchema | null) => {
const exportedFn = evaluateExport(code) as IFunction;
export const compileExportedFunction = (
code: string,
name: string,
source: string | null,
schema: JSONSchema | null,
) => {
const exportedFn = evaluateExport(code, source) as IFunction;

const fn = schema !== null ? decorateIFunctionWithSchemaValidation(exportedFn, schema) : exportedFn;

Expand Down
11 changes: 11 additions & 0 deletions src/rulesets/mergers/__tests__/functions.jest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('Ruleset functions merging', () => {
name: 'foo',
code: 'foo()',
schema: null,
source: null,
},
};

Expand All @@ -37,6 +38,7 @@ describe('Ruleset functions merging', () => {
name: 'foo',
code: 'foo()',
schema: null,
source: null,
});
});

Expand All @@ -46,13 +48,15 @@ describe('Ruleset functions merging', () => {
name: 'foo',
code: 'foo()',
schema: null,
source: null,
},
};
const sources: RulesetFunctionCollection = {
foo: {
name: 'foo.c',
code: 'foo.a()',
schema: null,
source: 'foo',
},
};

Expand All @@ -62,11 +66,13 @@ describe('Ruleset functions merging', () => {
name: 'foo.c',
code: 'foo.a()',
schema: null,
source: 'foo',
});
expect(target).toHaveProperty('foo', {
name: 'foo.c',
ref: 'random-id-0',
schema: null,
source: 'foo',
});
});

Expand All @@ -76,6 +82,7 @@ describe('Ruleset functions merging', () => {
name: 'foo',
code: 'foo()',
schema: null,
source: null,
},
};

Expand All @@ -84,11 +91,13 @@ describe('Ruleset functions merging', () => {
name: 'foo',
code: 'a.foo.c();',
schema: null,
source: null,
},
bar: {
name: 'bar',
code: 'bar()',
schema: null,
source: null,
},
};

Expand All @@ -112,11 +121,13 @@ describe('Ruleset functions merging', () => {
name: 'foo',
code: 'a.foo.c();',
schema: null,
source: null,
});
expect(target).toHaveProperty('random-id-1', {
name: 'bar',
code: 'bar()',
schema: null,
source: null,
});
});

Expand Down
1 change: 1 addition & 0 deletions src/rulesets/mergers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function mergeFunctions(
name: def.name,
schema: def.schema,
ref: newName,
source: def.source,
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/rulesets/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,17 @@ const createRulesetProcessor = (
rulesetFunctions.map(async fn => {
const fnName = Array.isArray(fn) ? fn[0] : fn;
const fnSchema = Array.isArray(fn) ? fn[1] : null;
const source = await findFile(rulesetFunctionsBaseDir, `./${fnName}.js`);

try {
resolvedFunctions[fnName] = {
name: fnName,
code: await readFile(await findFile(rulesetFunctionsBaseDir, `./${fnName}.js`), {
code: await readFile(source, {
timeout: readOpts && readOpts.timeout,
encoding: 'utf8',
}),
schema: fnSchema,
source,
};
} catch (ex) {
console.warn(`Function '${fnName}' could not be loaded: ${ex.message}`);
Expand Down
4 changes: 2 additions & 2 deletions src/spectral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class Spectral {

this.setFunctions(
Object.entries(ruleset.functions).reduce<FunctionCollection>(
(fns, [key, { code, ref, name, schema }]) => {
(fns, [key, { code, ref, name, source, schema }]) => {
if (code === void 0) {
if (ref !== void 0) {
({ code } = ruleset.functions[ref]);
Expand All @@ -168,7 +168,7 @@ export class Spectral {
return fns;
}

fns[key] = compileExportedFunction(code, name, schema);
fns[key] = compileExportedFunction(code, name, source, schema);
return fns;
},
{
Expand Down
1 change: 1 addition & 0 deletions src/types/ruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface IRulesetFunctionDefinition {
ref?: string;
schema: JSONSchema | null;
name: string;
source: string | null;
}

export type RulesetFunctionCollection = Dictionary<IRulesetFunctionDefinition, string>;
Expand Down

0 comments on commit c4f7d23

Please sign in to comment.