Skip to content

Commit c77aa92

Browse files
authored
Adds onCall function support (#9) (#34)
* 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
1 parent 819efbf commit c77aa92

File tree

4 files changed

+127
-21
lines changed

4 files changed

+127
-21
lines changed

spec/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ describe('index', () => {
6262
import './lifecycle.spec';
6363
import './main.spec';
6464
import './app.spec';
65+
import './providers/https.spec';
6566
// import './providers/analytics.spec';
6667
// import './providers/auth.spec';
6768
// import './providers/database.spec';

spec/main.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ describe('main', () => {
8888
expect(context.authType).to.equal('USER');
8989
});
9090

91+
it('should throw when passed invalid options', () => {
92+
const wrapped = wrap(constructCF());
93+
expect(() => wrapped('data', {
94+
auth: { uid: 'abc' },
95+
isInvalid: true,
96+
} as any)).to.throw();
97+
});
98+
9199
it('should generate the appropriate resource based on params', () => {
92100
const params = {
93101
wildcard: 'a',

spec/providers/https.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { expect } from 'chai';
2+
import * as functions from 'firebase-functions';
3+
import fft = require('../../src/index');
4+
5+
const cfToUpperCaseOnRequest = functions.https.onRequest((req, res) => {
6+
res.json({msg: req.params.message.toUpperCase()});
7+
});
8+
9+
const cfToUpperCaseOnCall = functions.https.onCall((data, context) => {
10+
const result = {
11+
msg: data.message.toUpperCase(),
12+
from: 'anonymous',
13+
};
14+
15+
if (context.auth && context.auth.uid) {
16+
result.from = context.auth.uid;
17+
}
18+
19+
return result;
20+
});
21+
22+
describe('providers/https', () => {
23+
it('should not throw when passed onRequest function', async () => {
24+
const test = fft();
25+
/*
26+
Note that we must cast the function to any here because onRequst functions
27+
do not fulfill Runnable<>, so these checks are solely for usage of this lib
28+
in JavaScript test suites.
29+
*/
30+
expect(() => test.wrap(cfToUpperCaseOnRequest as any)).to.throw();
31+
});
32+
33+
it('should run the wrapped onCall function and return result', async () => {
34+
const test = fft();
35+
const result = await test.wrap(cfToUpperCaseOnCall)({message: 'lowercase'});
36+
expect(result).to.deep.equal({msg: 'LOWERCASE', from: 'anonymous'});
37+
});
38+
39+
it('should accept auth params', async () => {
40+
const test = fft();
41+
const options = {auth: {uid: 'abc'}};
42+
const result = await test.wrap(cfToUpperCaseOnCall)({message: 'lowercase'}, options);
43+
expect(result).to.deep.equal({msg: 'LOWERCASE', from: 'abc'});
44+
});
45+
});

src/main.ts

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

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

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

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

47+
/** Fields of the callable context that can be overridden/customized. */
48+
export type CallableContextOptions = {
49+
/**
50+
* The result of decoding and verifying a Firebase Auth ID token.
51+
*/
52+
auth?: any;
53+
54+
/**
55+
* An unverified token for a Firebase Instance ID.
56+
*/
57+
instanceIdToken?: string;
58+
};
59+
60+
/* Fields for both Event and Callable contexts, checked at runtime */
61+
export type ContextOptions = EventContextOptions | CallableContextOptions;
62+
4763
/** A function that can be called with test data and optional override values for the event context.
4864
* It will subsequently invoke the cloud function it wraps with the provided test data and a generated event context.
4965
*/
50-
export type WrappedFunction = (data: any, options?: EventContextOptions) => any | Promise<any>;
66+
export type WrappedFunction = (data: any, options?: ContextOptions) => any | Promise<any>;
5167

5268
/** Takes a cloud function to be tested, and returns a WrappedFunction which can be called in test code. */
5369
export function wrap<T>(cloudFunction: CloudFunction<T>): WrappedFunction {
5470
if (!has(cloudFunction, '__trigger')) {
5571
throw new Error('Wrap can only be called on functions written with the firebase-functions SDK.');
5672
}
57-
if (!has(cloudFunction, '__trigger.eventTrigger')) {
58-
throw new Error('Wrap function is only available for non-HTTP functions.');
73+
74+
if (has(cloudFunction, '__trigger.httpsTrigger') &&
75+
(get(cloudFunction, '__trigger.labels.deployment-callable') !== 'true')) {
76+
throw new Error('Wrap function is only available for `onCall` HTTP functions, not `onRequest`.');
5977
}
78+
6079
if (!has(cloudFunction, 'run')) {
6180
throw new Error('This library can only be used with functions written with firebase-functions v1.0.0 and above');
6281
}
63-
let wrapped: WrappedFunction = (data: T, options: EventContextOptions) => {
64-
const defaultContext: EventContext = {
65-
eventId: _makeEventId(),
66-
resource: {
67-
service: cloudFunction.__trigger.eventTrigger.service,
68-
name: _makeResourceName(cloudFunction.__trigger.eventTrigger.resource, options? options.params: null),
69-
},
70-
eventType: cloudFunction.__trigger.eventTrigger.eventType,
71-
timestamp: (new Date()).toISOString(),
72-
params: {},
73-
};
74-
if (defaultContext.eventType.match(/firebase.database/)) {
75-
defaultContext.authType = 'UNAUTHENTICATED';
76-
defaultContext.auth = null;
82+
83+
const isCallableFunction = get(cloudFunction, '__trigger.labels.deployment-callable') === 'true';
84+
85+
let wrapped: WrappedFunction = (data: T, options: ContextOptions) => {
86+
// Although in Typescript we require `options` some of our JS samples do not pass it.
87+
options = options || {};
88+
let context;
89+
90+
if (isCallableFunction) {
91+
_checkOptionValidity(['auth', 'instanceIdToken'], options);
92+
let callableContextOptions = options as CallableContextOptions;
93+
context = {
94+
...callableContextOptions,
95+
rawRequest: 'rawRequest is not supported in firebase-functions-test',
96+
};
97+
} else {
98+
_checkOptionValidity(['eventId', 'timestamp', 'params', 'auth', 'authType'], options);
99+
let eventContextOptions = options as EventContextOptions;
100+
const defaultContext: EventContext = {
101+
eventId: _makeEventId(),
102+
resource: cloudFunction.__trigger.eventTrigger && {
103+
service: cloudFunction.__trigger.eventTrigger.service,
104+
name: _makeResourceName(
105+
cloudFunction.__trigger.eventTrigger.resource,
106+
has(eventContextOptions, 'params') && eventContextOptions.params,
107+
),
108+
},
109+
eventType: get(cloudFunction, '__trigger.eventTrigger.eventType'),
110+
timestamp: (new Date()).toISOString(),
111+
params: {},
112+
};
113+
114+
if (has(defaultContext, 'eventType') &&
115+
defaultContext.eventType.match(/firebase.database/)) {
116+
defaultContext.authType = 'UNAUTHENTICATED';
117+
defaultContext.auth = null;
118+
}
119+
context = merge({}, defaultContext, eventContextOptions);
77120
}
78-
let context = merge({}, defaultContext, options);
121+
79122
return cloudFunction.run(
80123
data,
81124
context,
82125
);
83126
};
127+
84128
return wrapped;
85129
}
86130

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

146+
function _checkOptionValidity(validFields: string[], options: {[s: string]: any}) {
147+
Object.keys(options).forEach((key) => {
148+
if (validFields.indexOf(key) === -1) {
149+
throw new Error(`Options object ${JSON.stringify(options)} has invalid key "${key}"`);
150+
}
151+
});
152+
}
153+
102154
/** Make a Change object to be used as test data for Firestore and real time database onWrite and onUpdate functions. */
103155
export function makeChange<T>(before: T, after: T): Change<T> {
104156
return Change.fromObjects(before, after);

0 commit comments

Comments
 (0)