Skip to content

Commit

Permalink
Adds onCall function support (#9) (#34)
Browse files Browse the repository at this point in the history
* Adds support for HTTPS onCall functions and improves HTTPS onRequest rejection #9

* Adds test for https auth params

* Begin adding CallableContext

* Adds ability to pass CallableContextOptions with runtime field checking

* Resolve onCall nits
  • Loading branch information
abeisgoat authored Dec 5, 2018
1 parent 819efbf commit c77aa92
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 21 deletions.
1 change: 1 addition & 0 deletions spec/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('index', () => {
import './lifecycle.spec';
import './main.spec';
import './app.spec';
import './providers/https.spec';
// import './providers/analytics.spec';
// import './providers/auth.spec';
// import './providers/database.spec';
Expand Down
8 changes: 8 additions & 0 deletions spec/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ describe('main', () => {
expect(context.authType).to.equal('USER');
});

it('should throw when passed invalid options', () => {
const wrapped = wrap(constructCF());
expect(() => wrapped('data', {
auth: { uid: 'abc' },
isInvalid: true,
} as any)).to.throw();
});

it('should generate the appropriate resource based on params', () => {
const params = {
wildcard: 'a',
Expand Down
45 changes: 45 additions & 0 deletions spec/providers/https.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect } from 'chai';
import * as functions from 'firebase-functions';
import fft = require('../../src/index');

const cfToUpperCaseOnRequest = functions.https.onRequest((req, res) => {
res.json({msg: req.params.message.toUpperCase()});
});

const cfToUpperCaseOnCall = functions.https.onCall((data, context) => {
const result = {
msg: data.message.toUpperCase(),
from: 'anonymous',
};

if (context.auth && context.auth.uid) {
result.from = context.auth.uid;
}

return result;
});

describe('providers/https', () => {
it('should not throw when passed onRequest function', async () => {
const test = fft();
/*
Note that we must cast the function to any here because onRequst functions
do not fulfill Runnable<>, so these checks are solely for usage of this lib
in JavaScript test suites.
*/
expect(() => test.wrap(cfToUpperCaseOnRequest as any)).to.throw();
});

it('should run the wrapped onCall function and return result', async () => {
const test = fft();
const result = await test.wrap(cfToUpperCaseOnCall)({message: 'lowercase'});
expect(result).to.deep.equal({msg: 'LOWERCASE', from: 'anonymous'});
});

it('should accept auth params', async () => {
const test = fft();
const options = {auth: {uid: 'abc'}};
const result = await test.wrap(cfToUpperCaseOnCall)({message: 'lowercase'}, options);
expect(result).to.deep.equal({msg: 'LOWERCASE', from: 'abc'});
});
});
94 changes: 73 additions & 21 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import { has, merge, random, get } from 'lodash';

import { CloudFunction, EventContext, Resource, Change } from 'firebase-functions';
import { CloudFunction, EventContext, Change } from 'firebase-functions';

/** Fields of the event context that can be overridden/customized. */
export type EventContextOptions = {
Expand All @@ -34,53 +34,97 @@ export type EventContextOptions = {
* If omitted, random values will be generated.
*/
params?: { [option: string]: any };
/** (Only for database functions.) Firebase auth variable representing the user that triggered
/** (Only for database functions and https.onCall.) Firebase auth variable representing the user that triggered
* the function. Defaults to null.
*/
auth?: any;
/** (Only for database functions.) The authentication state of the user that triggered the function.
/** (Only for database and https.onCall functions.) The authentication state of the user that triggered the function.
* Default is 'UNAUTHENTICATED'.
*/
authType?: 'ADMIN' | 'USER' | 'UNAUTHENTICATED';
};

/** Fields of the callable context that can be overridden/customized. */
export type CallableContextOptions = {
/**
* The result of decoding and verifying a Firebase Auth ID token.
*/
auth?: any;

/**
* An unverified token for a Firebase Instance ID.
*/
instanceIdToken?: string;
};

/* Fields for both Event and Callable contexts, checked at runtime */
export type ContextOptions = EventContextOptions | CallableContextOptions;

/** A function that can be called with test data and optional override values for the event context.
* It will subsequently invoke the cloud function it wraps with the provided test data and a generated event context.
*/
export type WrappedFunction = (data: any, options?: EventContextOptions) => any | Promise<any>;
export type WrappedFunction = (data: any, options?: ContextOptions) => any | Promise<any>;

/** Takes a cloud function to be tested, and returns a WrappedFunction which can be called in test code. */
export function wrap<T>(cloudFunction: CloudFunction<T>): WrappedFunction {
if (!has(cloudFunction, '__trigger')) {
throw new Error('Wrap can only be called on functions written with the firebase-functions SDK.');
}
if (!has(cloudFunction, '__trigger.eventTrigger')) {
throw new Error('Wrap function is only available for non-HTTP functions.');

if (has(cloudFunction, '__trigger.httpsTrigger') &&
(get(cloudFunction, '__trigger.labels.deployment-callable') !== 'true')) {
throw new Error('Wrap function is only available for `onCall` HTTP functions, not `onRequest`.');
}

if (!has(cloudFunction, 'run')) {
throw new Error('This library can only be used with functions written with firebase-functions v1.0.0 and above');
}
let wrapped: WrappedFunction = (data: T, options: EventContextOptions) => {
const defaultContext: EventContext = {
eventId: _makeEventId(),
resource: {
service: cloudFunction.__trigger.eventTrigger.service,
name: _makeResourceName(cloudFunction.__trigger.eventTrigger.resource, options? options.params: null),
},
eventType: cloudFunction.__trigger.eventTrigger.eventType,
timestamp: (new Date()).toISOString(),
params: {},
};
if (defaultContext.eventType.match(/firebase.database/)) {
defaultContext.authType = 'UNAUTHENTICATED';
defaultContext.auth = null;

const isCallableFunction = get(cloudFunction, '__trigger.labels.deployment-callable') === 'true';

let wrapped: WrappedFunction = (data: T, options: ContextOptions) => {
// Although in Typescript we require `options` some of our JS samples do not pass it.
options = options || {};
let context;

if (isCallableFunction) {
_checkOptionValidity(['auth', 'instanceIdToken'], options);
let callableContextOptions = options as CallableContextOptions;
context = {
...callableContextOptions,
rawRequest: 'rawRequest is not supported in firebase-functions-test',
};
} else {
_checkOptionValidity(['eventId', 'timestamp', 'params', 'auth', 'authType'], options);
let eventContextOptions = options as EventContextOptions;
const defaultContext: EventContext = {
eventId: _makeEventId(),
resource: cloudFunction.__trigger.eventTrigger && {
service: cloudFunction.__trigger.eventTrigger.service,
name: _makeResourceName(
cloudFunction.__trigger.eventTrigger.resource,
has(eventContextOptions, 'params') && eventContextOptions.params,
),
},
eventType: get(cloudFunction, '__trigger.eventTrigger.eventType'),
timestamp: (new Date()).toISOString(),
params: {},
};

if (has(defaultContext, 'eventType') &&
defaultContext.eventType.match(/firebase.database/)) {
defaultContext.authType = 'UNAUTHENTICATED';
defaultContext.auth = null;
}
context = merge({}, defaultContext, eventContextOptions);
}
let context = merge({}, defaultContext, options);

return cloudFunction.run(
data,
context,
);
};

return wrapped;
}

Expand All @@ -99,6 +143,14 @@ function _makeEventId(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

function _checkOptionValidity(validFields: string[], options: {[s: string]: any}) {
Object.keys(options).forEach((key) => {
if (validFields.indexOf(key) === -1) {
throw new Error(`Options object ${JSON.stringify(options)} has invalid key "${key}"`);
}
});
}

/** Make a Change object to be used as test data for Firestore and real time database onWrite and onUpdate functions. */
export function makeChange<T>(before: T, after: T): Change<T> {
return Change.fromObjects(before, after);
Expand Down

0 comments on commit c77aa92

Please sign in to comment.