Skip to content

Commit c1dd82b

Browse files
authored
Try to extract context.params from triggered data (#114)
* Try to extract context.params from triggered data * npm run format:fix
1 parent 84a50c2 commit c1dd82b

File tree

3 files changed

+256
-42
lines changed

3 files changed

+256
-42
lines changed

spec/main.spec.ts

Lines changed: 158 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@ import { expect } from 'chai';
2424
import * as functions from 'firebase-functions';
2525
import { set } from 'lodash';
2626

27-
import { mockConfig, makeChange, _makeResourceName, wrap } from '../src/main';
27+
import {
28+
mockConfig,
29+
makeChange,
30+
_makeResourceName,
31+
_extractParams,
32+
wrap,
33+
} from '../src/main';
34+
import { features } from '../src/features';
35+
import { FirebaseFunctionsTest } from '../src/lifecycle';
2836

2937
describe('main', () => {
3038
describe('#wrap', () => {
@@ -54,9 +62,9 @@ describe('main', () => {
5462
expect(typeof context.eventId).to.equal('string');
5563
expect(context.resource.service).to.equal('service');
5664
expect(
57-
/ref\/wildcard[1-9]\/nested\/anotherWildcard[1-9]/.test(
58-
context.resource.name
59-
)
65+
/ref\/wildcard[1-9]\/nested\/anotherWildcard[1-9]/.test(
66+
context.resource.name
67+
)
6068
).to.be.true;
6169
expect(context.eventType).to.equal('event');
6270
expect(Date.parse(context.timestamp)).to.be.greaterThan(0);
@@ -75,31 +83,49 @@ describe('main', () => {
7583
expect(context.timestamp).to.equal('2018-03-28T18:58:50.370Z');
7684
});
7785

78-
it('should generate auth and authType for database functions', () => {
79-
const context = wrap(constructBackgroundCF('google.firebase.database.ref.write'))(
80-
'data'
81-
).context;
82-
expect(context.auth).to.equal(null);
83-
expect(context.authType).to.equal('UNAUTHENTICATED');
84-
});
86+
describe('database functions', () => {
87+
let test;
88+
let change;
8589

86-
it('should allow auth and authType to be specified for database functions', () => {
87-
const wrapped = wrap(constructBackgroundCF('google.firebase.database.ref.write'));
88-
const context = wrapped('data', {
89-
auth: { uid: 'abc' },
90-
authType: 'USER',
91-
}).context;
92-
expect(context.auth).to.deep.equal({ uid: 'abc' });
93-
expect(context.authType).to.equal('USER');
90+
beforeEach(() => {
91+
test = new FirebaseFunctionsTest();
92+
test.init();
93+
change = features.database.exampleDataSnapshotChange();
94+
});
95+
96+
afterEach(() => {
97+
test.cleanup();
98+
});
99+
100+
it('should generate auth and authType', () => {
101+
const wrapped = wrap(
102+
constructBackgroundCF('google.firebase.database.ref.write')
103+
);
104+
const context = wrapped(change).context;
105+
expect(context.auth).to.equal(null);
106+
expect(context.authType).to.equal('UNAUTHENTICATED');
107+
});
108+
109+
it('should allow auth and authType to be specified', () => {
110+
const wrapped = wrap(
111+
constructBackgroundCF('google.firebase.database.ref.write')
112+
);
113+
const context = wrapped(change, {
114+
auth: { uid: 'abc' },
115+
authType: 'USER',
116+
}).context;
117+
expect(context.auth).to.deep.equal({ uid: 'abc' });
118+
expect(context.authType).to.equal('USER');
119+
});
94120
});
95121

96122
it('should throw when passed invalid options', () => {
97123
const wrapped = wrap(constructBackgroundCF());
98124
expect(() =>
99-
wrapped('data', {
100-
auth: { uid: 'abc' },
101-
isInvalid: true,
102-
} as any)
125+
wrapped('data', {
126+
auth: { uid: 'abc' },
127+
isInvalid: true,
128+
} as any)
103129
).to.throw();
104130
});
105131

@@ -113,6 +139,91 @@ describe('main', () => {
113139
expect(context.params).to.deep.equal(params);
114140
expect(context.resource.name).to.equal('ref/a/nested/b');
115141
});
142+
143+
describe('Params extraction', () => {
144+
let test;
145+
146+
beforeEach(() => {
147+
test = new FirebaseFunctionsTest();
148+
test.init();
149+
});
150+
151+
afterEach(() => {
152+
test.cleanup();
153+
});
154+
155+
it('should extract the appropriate params for database function trigger', () => {
156+
const cf = constructBackgroundCF(
157+
'google.firebase.database.ref.create'
158+
);
159+
cf.__trigger.eventTrigger.resource =
160+
'companies/{company}/users/{user}';
161+
const wrapped = wrap(cf);
162+
const context = wrapped(
163+
features.database.makeDataSnapshot(
164+
{ foo: 'bar' },
165+
'companies/Google/users/Lauren'
166+
)
167+
).context;
168+
expect(context.params).to.deep.equal({
169+
company: 'Google',
170+
user: 'Lauren',
171+
});
172+
expect(context.resource.name).to.equal(
173+
'companies/Google/users/Lauren'
174+
);
175+
});
176+
177+
it('should extract the appropriate params for Firestore function trigger', () => {
178+
const cf = constructBackgroundCF('google.firestore.document.create');
179+
cf.__trigger.eventTrigger.resource =
180+
'databases/(default)/documents/companies/{company}/users/{user}';
181+
const wrapped = wrap(cf);
182+
const context = wrapped(
183+
features.firestore.makeDocumentSnapshot(
184+
{ foo: 'bar' },
185+
'companies/Google/users/Lauren'
186+
)
187+
).context;
188+
expect(context.params).to.deep.equal({
189+
company: 'Google',
190+
user: 'Lauren',
191+
});
192+
expect(context.resource.name).to.equal(
193+
'databases/(default)/documents/companies/Google/users/Lauren'
194+
);
195+
});
196+
197+
it('should prefer provided context.params over the extracted params', () => {
198+
const cf = constructBackgroundCF(
199+
'google.firebase.database.ref.create'
200+
);
201+
cf.__trigger.eventTrigger.resource =
202+
'companies/{company}/users/{user}';
203+
const wrapped = wrap(cf);
204+
const context = wrapped(
205+
features.database.makeDataSnapshot(
206+
{ foo: 'bar' },
207+
'companies/Google/users/Lauren'
208+
),
209+
{
210+
params: {
211+
company: 'Alphabet',
212+
user: 'Lauren',
213+
foo: 'bar',
214+
},
215+
}
216+
).context;
217+
expect(context.params).to.deep.equal({
218+
company: 'Alphabet',
219+
user: 'Lauren',
220+
foo: 'bar',
221+
});
222+
expect(context.resource.name).to.equal(
223+
'companies/Alphabet/users/Lauren'
224+
);
225+
});
226+
});
116227
});
117228

118229
describe('callable functions', () => {
@@ -141,23 +252,22 @@ describe('main', () => {
141252
auth: { uid: 'abc' },
142253
app: { appId: 'efg' },
143254
instanceIdToken: '123',
144-
rawRequest: { body: 'hello' }
255+
rawRequest: { body: 'hello' },
145256
}).context;
146257
expect(context.auth).to.deep.equal({ uid: 'abc' });
147258
expect(context.app).to.deep.equal({ appId: 'efg' });
148259
expect(context.instanceIdToken).to.equal('123');
149-
expect(context.rawRequest).to.deep.equal({ body: 'hello'});
260+
expect(context.rawRequest).to.deep.equal({ body: 'hello' });
150261
});
151262

152263
it('should throw when passed invalid options', () => {
153264
expect(() =>
154-
wrappedCF('data', {
155-
auth: { uid: 'abc' },
156-
isInvalid: true,
157-
} as any)
265+
wrappedCF('data', {
266+
auth: { uid: 'abc' },
267+
isInvalid: true,
268+
} as any)
158269
).to.throw();
159270
});
160-
161271
});
162272
});
163273

@@ -171,6 +281,24 @@ describe('main', () => {
171281
});
172282
});
173283

284+
describe('#_extractParams', () => {
285+
it('should not extract any params', () => {
286+
const params = _extractParams('users/foo', 'users/foo');
287+
expect(params).to.deep.equal({});
288+
});
289+
290+
it('should extract params', () => {
291+
const params = _extractParams(
292+
'companies/{company}/users/{user}',
293+
'companies/Google/users/Lauren'
294+
);
295+
expect(params).to.deep.equal({
296+
company: 'Google',
297+
user: 'Lauren',
298+
});
299+
});
300+
});
301+
174302
describe('#makeChange', () => {
175303
it('should make a Change object with the correct before and after', () => {
176304
const change = makeChange('before', 'after');

src/main.ts

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
Change,
2929
https,
3030
config,
31+
database,
32+
firestore,
3133
} from 'firebase-functions';
3234

3335
/** Fields of the event context that can be overridden/customized. */
@@ -154,7 +156,10 @@ export function wrap<T>(
154156
let context;
155157

156158
if (isCallableFunction) {
157-
_checkOptionValidity(['app', 'auth', 'instanceIdToken', 'rawRequest'], options);
159+
_checkOptionValidity(
160+
['app', 'auth', 'instanceIdToken', 'rawRequest'],
161+
options
162+
);
158163
let callableContextOptions = options as CallableContextOptions;
159164
context = {
160165
...callableContextOptions,
@@ -164,7 +169,7 @@ export function wrap<T>(
164169
['eventId', 'timestamp', 'params', 'auth', 'authType', 'resource'],
165170
options
166171
);
167-
const defaultContext = _makeDefaultContext(cloudFunction, options);
172+
const defaultContext = _makeDefaultContext(cloudFunction, options, data);
168173

169174
if (
170175
has(defaultContext, 'eventType') &&
@@ -223,25 +228,104 @@ function _checkOptionValidity(
223228

224229
function _makeDefaultContext<T>(
225230
cloudFunction: CloudFunction<T>,
226-
options: ContextOptions
231+
options: ContextOptions,
232+
triggerData?: T
227233
): EventContext {
228234
let eventContextOptions = options as EventContextOptions;
235+
const eventResource = cloudFunction.__trigger.eventTrigger?.resource;
236+
const eventType = cloudFunction.__trigger.eventTrigger?.eventType;
237+
238+
const optionsParams = eventContextOptions.params ?? {};
239+
let triggerParams = {};
240+
if (eventResource && eventType && triggerData) {
241+
if (eventType.startsWith('google.firebase.database.ref.')) {
242+
let data: database.DataSnapshot;
243+
if (eventType.endsWith('.write')) {
244+
// Triggered with change
245+
if (!(triggerData instanceof Change)) {
246+
throw new Error('Must be triggered by database change');
247+
}
248+
data = triggerData.before;
249+
} else {
250+
data = triggerData as any;
251+
}
252+
triggerParams = _extractDatabaseParams(eventResource, data);
253+
} else if (eventType.startsWith('google.firestore.document.')) {
254+
let data: firestore.DocumentSnapshot;
255+
if (eventType.endsWith('.write')) {
256+
// Triggered with change
257+
if (!(triggerData instanceof Change)) {
258+
throw new Error('Must be triggered by firestore document change');
259+
}
260+
data = triggerData.before;
261+
} else {
262+
data = triggerData as any;
263+
}
264+
triggerParams = _extractFirestoreDocumentParams(eventResource, data);
265+
}
266+
}
267+
const params = { ...triggerParams, ...optionsParams };
268+
229269
const defaultContext: EventContext = {
230270
eventId: _makeEventId(),
231-
resource: cloudFunction.__trigger.eventTrigger && {
232-
service: cloudFunction.__trigger.eventTrigger.service,
233-
name: _makeResourceName(
234-
cloudFunction.__trigger.eventTrigger.resource,
235-
has(eventContextOptions, 'params') && eventContextOptions.params
236-
),
271+
resource: eventResource && {
272+
service: cloudFunction.__trigger.eventTrigger?.service,
273+
name: _makeResourceName(eventResource, params),
237274
},
238-
eventType: get(cloudFunction, '__trigger.eventTrigger.eventType'),
275+
eventType,
239276
timestamp: new Date().toISOString(),
240-
params: {},
277+
params,
241278
};
242279
return defaultContext;
243280
}
244281

282+
function _extractDatabaseParams(
283+
triggerResource: string,
284+
data: database.DataSnapshot
285+
): EventContext['params'] {
286+
const path = data.ref.toString().replace(data.ref.root.toString(), '');
287+
return _extractParams(triggerResource, path);
288+
}
289+
290+
function _extractFirestoreDocumentParams(
291+
triggerResource: string,
292+
data: firestore.DocumentSnapshot
293+
): EventContext['params'] {
294+
// Resource format: databases/(default)/documents/<path>
295+
return _extractParams(
296+
triggerResource.replace(/^databases\/[^\/]+\/documents\//, ''),
297+
data.ref.path
298+
);
299+
}
300+
301+
/**
302+
* Extracts the `{wildcard}` values from `dataPath`.
303+
* E.g. A wildcard path of `users/{userId}` with `users/FOO` would result in `{ userId: 'FOO' }`.
304+
* @internal
305+
*/
306+
export function _extractParams(
307+
wildcardTriggerPath: string,
308+
dataPath: string
309+
): EventContext['params'] {
310+
// Trim start and end / and split into path components
311+
const wildcardPaths = wildcardTriggerPath
312+
.replace(/^\/?(.*?)\/?$/, '$1')
313+
.split('/');
314+
const dataPaths = dataPath.replace(/^\/?(.*?)\/?$/, '$1').split('/');
315+
const params = {};
316+
if (wildcardPaths.length === dataPaths.length) {
317+
for (let idx = 0; idx < wildcardPaths.length; idx++) {
318+
const wildcardPath = wildcardPaths[idx];
319+
const name = wildcardPath.replace(/^{([^/{}]*)}$/, '$1');
320+
if (name !== wildcardPath) {
321+
// Wildcard parameter
322+
params[name] = dataPaths[idx];
323+
}
324+
}
325+
}
326+
return params;
327+
}
328+
245329
/** Make a Change object to be used as test data for Firestore and real time database onWrite and onUpdate functions. */
246330
export function makeChange<T>(before: T, after: T): Change<T> {
247331
return Change.fromObjects(before, after);

0 commit comments

Comments
 (0)