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

feat(ruleset): allow require calls in Node.JS #1011

Merged
merged 9 commits into from
Mar 15, 2020
Merged
Show file tree
Hide file tree
Changes from 6 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
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!';
39 changes: 39 additions & 0 deletions src/rulesets/__tests__/evaluators.jest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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('/hello!');

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

it('supports require.resolve', () => {
expect(
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);
};

XVincentX marked this conversation as resolved.
Show resolved Hide resolved
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