Skip to content

Commit

Permalink
feat(ruleset): allow require calls in Node.JS (#1011)
Browse files Browse the repository at this point in the history
* feat(ruleset): allow require calls in Node.JS

* test: cover require

* docs: add a note on require

* docs: remove comment

Co-Authored-By: Phil Sturgeon <[email protected]>

* doc: note on require

Co-Authored-By: Phil Sturgeon <[email protected]>

* test: minor tweak

Co-authored-by: Phil Sturgeon <[email protected]>
  • Loading branch information
P0lip and Phil Sturgeon authored Mar 15, 2020
1 parent c969a32 commit d5f11ba
Show file tree
Hide file tree
Showing 15 changed files with 210 additions and 21 deletions.
4 changes: 2 additions & 2 deletions docs/guides/custom-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,10 @@ export default (obj) => {
};
```

The following code won't work properly either:
Require calls will work only in Node.js, and will cause errors for anyone trying to use the ruleset in the browser. If your ruleset is definitely going to only be used in the context of NodeJS then using them is ok, but if you are distributing your rulesets to the public we recommend avoiding the use of `require()` to increase portability.

```js
const foo = require('./foo'); // require is not available (see note below)
const foo = require('./foo');
module.exports = (obj) => {
for (const [key, value] of Object.entries(obj)) {
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/__fixtures__/custom-functions-oas-ruleset.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": ["spectral:oas"],
"functions": [
"truthy",
"deepHasIn",
[
"hasIn",
{
Expand Down Expand Up @@ -48,6 +49,17 @@
"foo": true
}
}
},
"has-bar-get-operation": {
"message": "{{error}}",
"given": "$.paths",
"type": "style",
"then": {
"function": "deepHasIn",
"functionOptions": {
"path": "/bar.get"
}
}
}
}
}
11 changes: 11 additions & 0 deletions src/__tests__/__fixtures__/functions/deepHasIn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { hasIn } = require('lodash');

module.exports = (targetVal, opts) => {
if (!(hasIn(targetVal, opts.path))) {
return [
{
message: `Object does not have ${opts.prop} property`,
},
];
}
};
16 changes: 16 additions & 0 deletions src/__tests__/linter.jest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ describe('Linter', () => {
);
});

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 respect the scope of defined functions (ruleset-based)', async () => {
await spectral.loadRuleset(customDirectoryFunctionsRuleset);
expect(await spectral.run({})).toEqual([
Expand Down
1 change: 1 addition & 0 deletions src/rulesets/__tests__/__fixtures__/foo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = () => 'hello!';
41 changes: 41 additions & 0 deletions src/rulesets/__tests__/evaluators.jest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as path from '@stoplight/path';
import { evaluateExport } from '../evaluators';

describe('Code evaluators', () => {
describe('Export evaluator', () => {
it('supports require', () => {
expect(evaluateExport(`module.exports = require('./foo')`, path.join(__dirname, '__fixtures__/a.js'))()).toEqual(
'hello!',
);

expect(
evaluateExport(
`module.exports = () => require('path').join('/', 'hello!')`,
path.join(__dirname, '__fixtures__/a.js'),
)(),
).toEqual(require('path').join('/', 'hello!'));

expect(
evaluateExport(
`module.exports = () => require('@stoplight/path').join('/', 'hello!')`,
path.join(__dirname, '__fixtures__/a.js'),
)(),
).toEqual(path.join('/', 'hello!'));
});

it('supports require.resolve', () => {
expect(
path.normalize(
evaluateExport(
`module.exports = () => require.resolve('./foo', { paths: ['${path.join(__dirname, '__fixtures__')}'] } )`,
null,
)(),
),
).toEqual(path.join(__dirname, '__fixtures__/foo.js'));
});

it.each(['cache', 'extensions'])('exposes %s', member => {
expect(evaluateExport(`module.exports = () => require['${member}']`, null)()).toStrictEqual(require[member]);
});
});
});
13 changes: 13 additions & 0 deletions src/rulesets/__tests__/evaluators.karma.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { evaluateExport } from '../evaluators';

describe('Code evaluators', () => {
describe('Export evaluator', () => {
it('does not support require', () => {
expect(evaluateExport.bind(null, `require('./d')`, null)).toThrowError(ReferenceError);
expect(evaluateExport.bind(null, `require.resolve('./d')`, null)).toThrowError(ReferenceError);
expect(evaluateExport.bind(null, `require.main`, null)).toThrowError(ReferenceError);
expect(evaluateExport.bind(null, `require.cache`, null)).toThrowError(ReferenceError);
expect(evaluateExport.bind(null, `require.extensions`, null)).toThrowError(ReferenceError);
});
});
});
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
86 changes: 80 additions & 6 deletions src/rulesets/evaluators.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,79 @@
import { Optional } from '@stoplight/types';
import { 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 (p.startsWith('.')) {
p = join(source, '..', stripRoot(p));
}

return actualRequire.call(null, p);
}

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

const isRequiredSupported =
typeof require === 'function' &&
typeof require.main === 'object' &&
require.main !== null &&
'paths' in require.main &&
'cache' in require;

const createRequire = (source: string | null): NodeJS.Require => {
if (!isRequiredSupported) {
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 +99,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 +130,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
Loading

0 comments on commit d5f11ba

Please sign in to comment.