Skip to content

Commit

Permalink
feat: add callbackify() and callbackifyAll() methods (#82)
Browse files Browse the repository at this point in the history
  • Loading branch information
AVaksman authored and JustinBeckwith committed Feb 13, 2019
1 parent 3569647 commit c6127bb
Show file tree
Hide file tree
Showing 3 changed files with 209 additions and 2 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<img src="https://avatars2.githubusercontent.com/u/2810941?v=3&s=96" alt="Google Cloud Platform logo" title="Google Cloud Platform" align="right" height="96" width="96"/>

# @google-cloud/promisify
> A simple utility for promisifying functions and classes.
> A simple utility for promisifying and callbackifying functions and classes.
Google Cloud Common node.js module contains stuff used by other Cloud API modules.

Expand Down Expand Up @@ -31,7 +31,7 @@ More Information: [Google Cloud Platform Launch Stages][launch_stages]

## Contributing

Contributions welcome! See the [Contributing Guide](https://github.com/googlecloudplatform/google-cloud-node/blob/master/CONTRIBUTING.md).
Contributions welcome! See the [Contributing Guide](https://github.com/googleapis/nodejs-promisify/blob/master/CONTRIBUTING.md).

## License

Expand Down
75 changes: 75 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ export interface WithPromise {
Promise?: PromiseConstructor;
}

export interface CallbackifyAllOptions {
/**
* Array of methods to ignore when callbackifying.
*/
exclude?: string[];
}

export interface CallbackMethod extends Function {
callbackified_?: boolean;
}

/**
* Wraps a callback style function to conditionally return a promise.
*
Expand Down Expand Up @@ -139,3 +150,67 @@ export function promisifyAll(Class: Function, options?: PromisifyAllOptions) {
}
});
}

/**
* Wraps a promisy type function to conditionally call a callback function.
*
* @param {function} originalMethod - The method to callbackify.
* @param {object=} options - Callback options.
* @param {boolean} options.singular - Pass to the callback a single arg instead of an array.
* @return {function} wrapped
*/
export function callbackify(originalMethod: CallbackMethod) {
if (originalMethod.callbackified_) {
return originalMethod;
}

// tslint:disable-next-line:no-any
const wrapper = function(this: any) {
const context = this;

if (typeof arguments[arguments.length - 1] !== 'function') {
return originalMethod.apply(context, arguments);
}

const cb = Array.prototype.pop.call(arguments);

originalMethod
.apply(context, arguments)
// tslint:disable-next-line:no-any
.then((res: any) => {
res = Array.isArray(res) ? res : [res];
cb(null, ...res);
}, (err: Error) => cb(err));
};
wrapper.callbackified_ = true;
return wrapper;
}

/**
* Callbackifies certain Class methods. This will not callbackify private or
* streaming methods.
*
* @param {module:common/service} Class - Service class.
* @param {object=} options - Configuration object.
*/
export function callbackifyAll(
// tslint:disable-next-line:variable-name
Class: Function, options?: CallbackifyAllOptions) {
const exclude = (options && options.exclude) || [];
const ownPropertyNames = Object.getOwnPropertyNames(Class.prototype);
const methods = ownPropertyNames.filter((methodName) => {
// clang-format off
return (typeof Class.prototype[methodName] === 'function' && // is it a function?
!/^_|(Stream|_)|^constructor$/.test(methodName) && // is it callbackifyable?
exclude.indexOf(methodName) === -1
); // is it blacklisted?
// clang-format on
});

methods.forEach((methodName) => {
const originalMethod = Class.prototype[methodName];
if (!originalMethod.callbackified_) {
Class.prototype[methodName] = exports.callbackify(originalMethod);
}
});
}
132 changes: 132 additions & 0 deletions test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
*/

import * as assert from 'assert';
import * as mocha from 'mocha';
import * as sinon from 'sinon';

import * as util from '../src';

const noop = () => {};
Expand Down Expand Up @@ -247,3 +249,133 @@ describe('promisify', () => {
});
});
});

describe('callbackifyAll', () => {
const fakeArgs = [1, 2, 3];
const fakeError = new Error('err.');

// tslint:disable-next-line
let FakeClass: any;

beforeEach(() => {
FakeClass = class {
async methodName() {
return fakeArgs;
}
async methodError() {
throw fakeError;
}
};
FakeClass.prototype.method_ = noop;
FakeClass.prototype._method = noop;
FakeClass.prototype.methodStream = noop;

util.callbackifyAll(FakeClass);
});

it('should callbackify the correct method', () => {
assert(FakeClass.prototype.methodName.callbackified_);
assert(FakeClass.prototype.methodError.callbackified_);

assert.strictEqual(FakeClass.prototype.method_, noop);
assert.strictEqual(FakeClass.prototype._method, noop);
assert.strictEqual(FakeClass.prototype.methodStream, noop);
});

it('should optionally accept an exclude list', () => {
function FakeClass2() {}
FakeClass2.prototype.methodSync = noop;
FakeClass2.prototype.method = () => {};
util.callbackifyAll(FakeClass2, {
exclude: ['methodSync'],
});
assert.strictEqual(FakeClass2.prototype.methodSync, noop);
assert(FakeClass2.prototype.method.callbackified_);
assert.strictEqual(FakeClass2.prototype.methodSync, noop);
});

it('should not re-callbackify method', () => {
const method = FakeClass.prototype.methodName;
util.callbackifyAll(FakeClass);
assert.strictEqual(FakeClass.prototype.methodName, method);
});
});

describe('callbackify', () => {
let func: Function;
// tslint:disable-next-line:no-any
let fakeArgs: any[];

beforeEach(() => {
fakeArgs = [1, 2, 3];

func = util.callbackify(async function(this: {}) {
return fakeArgs;
});
});

it('should not re-callbackify the function', () => {
const original = func;
func = util.callbackify(func);
assert.strictEqual(original, func);
});

it('should return a promise when callback is not provided', () => {
func().then((args: []) => {
assert.deepStrictEqual(args, fakeArgs);
});
});

it('should call the callback if it is provided', (done) => {
func(function(this: {}) {
const args = [].slice.call(arguments);
assert.deepStrictEqual(args, [null, ...fakeArgs]);
done();
});
});

it('should call the provided callback with undefined', (done) => {
func = util.callbackify(async function(this: {}) {});
// tslint:disable-next-line:no-any
func((err: Error, resp: any) => {
assert.strictEqual(err, null);
assert.strictEqual(resp, undefined);
done();
});
});

it('should call the provided callback with null', (done) => {
func = util.callbackify(async function(this: {}) {
return null;
});
func(function(this: {}) {
const args = [].slice.call(arguments);
assert.deepStrictEqual(args, [null, null]);
done();
});
});

it('should call the callback with error when promise rejects', () => {
const error = new Error('err');
func = util.callbackify(async () => {
throw error;
});
func((err: Error) => assert.strictEqual(err, error));
});

it('should call the callback only a single time when the promise resolves but callback throws an error',
() => {
const error = new Error('err');
const callback = sinon.stub().throws(error);

const originalRejection = process.listeners('unhandledRejection').pop();
process.removeListener('unhandledRejection', originalRejection!);
process.once('unhandledRejection', (err) => {
assert.strictEqual(error, err);
assert.ok(callback.calledOnce);
process.listeners('unhandledRejection').push(originalRejection!);
});

func(callback);
});
});

0 comments on commit c6127bb

Please sign in to comment.