Skip to content

Commit

Permalink
Try to extract context.params from triggered data (#114)
Browse files Browse the repository at this point in the history
* Try to extract context.params from triggered data

* npm run format:fix
  • Loading branch information
rhodgkins authored Nov 4, 2021
1 parent 84a50c2 commit c1dd82b
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 42 deletions.
188 changes: 158 additions & 30 deletions spec/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ import { expect } from 'chai';
import * as functions from 'firebase-functions';
import { set } from 'lodash';

import { mockConfig, makeChange, _makeResourceName, wrap } from '../src/main';
import {
mockConfig,
makeChange,
_makeResourceName,
_extractParams,
wrap,
} from '../src/main';
import { features } from '../src/features';
import { FirebaseFunctionsTest } from '../src/lifecycle';

describe('main', () => {
describe('#wrap', () => {
Expand Down Expand Up @@ -54,9 +62,9 @@ describe('main', () => {
expect(typeof context.eventId).to.equal('string');
expect(context.resource.service).to.equal('service');
expect(
/ref\/wildcard[1-9]\/nested\/anotherWildcard[1-9]/.test(
context.resource.name
)
/ref\/wildcard[1-9]\/nested\/anotherWildcard[1-9]/.test(
context.resource.name
)
).to.be.true;
expect(context.eventType).to.equal('event');
expect(Date.parse(context.timestamp)).to.be.greaterThan(0);
Expand All @@ -75,31 +83,49 @@ describe('main', () => {
expect(context.timestamp).to.equal('2018-03-28T18:58:50.370Z');
});

it('should generate auth and authType for database functions', () => {
const context = wrap(constructBackgroundCF('google.firebase.database.ref.write'))(
'data'
).context;
expect(context.auth).to.equal(null);
expect(context.authType).to.equal('UNAUTHENTICATED');
});
describe('database functions', () => {
let test;
let change;

it('should allow auth and authType to be specified for database functions', () => {
const wrapped = wrap(constructBackgroundCF('google.firebase.database.ref.write'));
const context = wrapped('data', {
auth: { uid: 'abc' },
authType: 'USER',
}).context;
expect(context.auth).to.deep.equal({ uid: 'abc' });
expect(context.authType).to.equal('USER');
beforeEach(() => {
test = new FirebaseFunctionsTest();
test.init();
change = features.database.exampleDataSnapshotChange();
});

afterEach(() => {
test.cleanup();
});

it('should generate auth and authType', () => {
const wrapped = wrap(
constructBackgroundCF('google.firebase.database.ref.write')
);
const context = wrapped(change).context;
expect(context.auth).to.equal(null);
expect(context.authType).to.equal('UNAUTHENTICATED');
});

it('should allow auth and authType to be specified', () => {
const wrapped = wrap(
constructBackgroundCF('google.firebase.database.ref.write')
);
const context = wrapped(change, {
auth: { uid: 'abc' },
authType: 'USER',
}).context;
expect(context.auth).to.deep.equal({ uid: 'abc' });
expect(context.authType).to.equal('USER');
});
});

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

Expand All @@ -113,6 +139,91 @@ describe('main', () => {
expect(context.params).to.deep.equal(params);
expect(context.resource.name).to.equal('ref/a/nested/b');
});

describe('Params extraction', () => {
let test;

beforeEach(() => {
test = new FirebaseFunctionsTest();
test.init();
});

afterEach(() => {
test.cleanup();
});

it('should extract the appropriate params for database function trigger', () => {
const cf = constructBackgroundCF(
'google.firebase.database.ref.create'
);
cf.__trigger.eventTrigger.resource =
'companies/{company}/users/{user}';
const wrapped = wrap(cf);
const context = wrapped(
features.database.makeDataSnapshot(
{ foo: 'bar' },
'companies/Google/users/Lauren'
)
).context;
expect(context.params).to.deep.equal({
company: 'Google',
user: 'Lauren',
});
expect(context.resource.name).to.equal(
'companies/Google/users/Lauren'
);
});

it('should extract the appropriate params for Firestore function trigger', () => {
const cf = constructBackgroundCF('google.firestore.document.create');
cf.__trigger.eventTrigger.resource =
'databases/(default)/documents/companies/{company}/users/{user}';
const wrapped = wrap(cf);
const context = wrapped(
features.firestore.makeDocumentSnapshot(
{ foo: 'bar' },
'companies/Google/users/Lauren'
)
).context;
expect(context.params).to.deep.equal({
company: 'Google',
user: 'Lauren',
});
expect(context.resource.name).to.equal(
'databases/(default)/documents/companies/Google/users/Lauren'
);
});

it('should prefer provided context.params over the extracted params', () => {
const cf = constructBackgroundCF(
'google.firebase.database.ref.create'
);
cf.__trigger.eventTrigger.resource =
'companies/{company}/users/{user}';
const wrapped = wrap(cf);
const context = wrapped(
features.database.makeDataSnapshot(
{ foo: 'bar' },
'companies/Google/users/Lauren'
),
{
params: {
company: 'Alphabet',
user: 'Lauren',
foo: 'bar',
},
}
).context;
expect(context.params).to.deep.equal({
company: 'Alphabet',
user: 'Lauren',
foo: 'bar',
});
expect(context.resource.name).to.equal(
'companies/Alphabet/users/Lauren'
);
});
});
});

describe('callable functions', () => {
Expand Down Expand Up @@ -141,23 +252,22 @@ describe('main', () => {
auth: { uid: 'abc' },
app: { appId: 'efg' },
instanceIdToken: '123',
rawRequest: { body: 'hello' }
rawRequest: { body: 'hello' },
}).context;
expect(context.auth).to.deep.equal({ uid: 'abc' });
expect(context.app).to.deep.equal({ appId: 'efg' });
expect(context.instanceIdToken).to.equal('123');
expect(context.rawRequest).to.deep.equal({ body: 'hello'});
expect(context.rawRequest).to.deep.equal({ body: 'hello' });
});

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

});
});

Expand All @@ -171,6 +281,24 @@ describe('main', () => {
});
});

describe('#_extractParams', () => {
it('should not extract any params', () => {
const params = _extractParams('users/foo', 'users/foo');
expect(params).to.deep.equal({});
});

it('should extract params', () => {
const params = _extractParams(
'companies/{company}/users/{user}',
'companies/Google/users/Lauren'
);
expect(params).to.deep.equal({
company: 'Google',
user: 'Lauren',
});
});
});

describe('#makeChange', () => {
it('should make a Change object with the correct before and after', () => {
const change = makeChange('before', 'after');
Expand Down
106 changes: 95 additions & 11 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
Change,
https,
config,
database,
firestore,
} from 'firebase-functions';

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

if (isCallableFunction) {
_checkOptionValidity(['app', 'auth', 'instanceIdToken', 'rawRequest'], options);
_checkOptionValidity(
['app', 'auth', 'instanceIdToken', 'rawRequest'],
options
);
let callableContextOptions = options as CallableContextOptions;
context = {
...callableContextOptions,
Expand All @@ -164,7 +169,7 @@ export function wrap<T>(
['eventId', 'timestamp', 'params', 'auth', 'authType', 'resource'],
options
);
const defaultContext = _makeDefaultContext(cloudFunction, options);
const defaultContext = _makeDefaultContext(cloudFunction, options, data);

if (
has(defaultContext, 'eventType') &&
Expand Down Expand Up @@ -223,25 +228,104 @@ function _checkOptionValidity(

function _makeDefaultContext<T>(
cloudFunction: CloudFunction<T>,
options: ContextOptions
options: ContextOptions,
triggerData?: T
): EventContext {
let eventContextOptions = options as EventContextOptions;
const eventResource = cloudFunction.__trigger.eventTrigger?.resource;
const eventType = cloudFunction.__trigger.eventTrigger?.eventType;

const optionsParams = eventContextOptions.params ?? {};
let triggerParams = {};
if (eventResource && eventType && triggerData) {
if (eventType.startsWith('google.firebase.database.ref.')) {
let data: database.DataSnapshot;
if (eventType.endsWith('.write')) {
// Triggered with change
if (!(triggerData instanceof Change)) {
throw new Error('Must be triggered by database change');
}
data = triggerData.before;
} else {
data = triggerData as any;
}
triggerParams = _extractDatabaseParams(eventResource, data);
} else if (eventType.startsWith('google.firestore.document.')) {
let data: firestore.DocumentSnapshot;
if (eventType.endsWith('.write')) {
// Triggered with change
if (!(triggerData instanceof Change)) {
throw new Error('Must be triggered by firestore document change');
}
data = triggerData.before;
} else {
data = triggerData as any;
}
triggerParams = _extractFirestoreDocumentParams(eventResource, data);
}
}
const params = { ...triggerParams, ...optionsParams };

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
),
resource: eventResource && {
service: cloudFunction.__trigger.eventTrigger?.service,
name: _makeResourceName(eventResource, params),
},
eventType: get(cloudFunction, '__trigger.eventTrigger.eventType'),
eventType,
timestamp: new Date().toISOString(),
params: {},
params,
};
return defaultContext;
}

function _extractDatabaseParams(
triggerResource: string,
data: database.DataSnapshot
): EventContext['params'] {
const path = data.ref.toString().replace(data.ref.root.toString(), '');
return _extractParams(triggerResource, path);
}

function _extractFirestoreDocumentParams(
triggerResource: string,
data: firestore.DocumentSnapshot
): EventContext['params'] {
// Resource format: databases/(default)/documents/<path>
return _extractParams(
triggerResource.replace(/^databases\/[^\/]+\/documents\//, ''),
data.ref.path
);
}

/**
* Extracts the `{wildcard}` values from `dataPath`.
* E.g. A wildcard path of `users/{userId}` with `users/FOO` would result in `{ userId: 'FOO' }`.
* @internal
*/
export function _extractParams(
wildcardTriggerPath: string,
dataPath: string
): EventContext['params'] {
// Trim start and end / and split into path components
const wildcardPaths = wildcardTriggerPath
.replace(/^\/?(.*?)\/?$/, '$1')
.split('/');
const dataPaths = dataPath.replace(/^\/?(.*?)\/?$/, '$1').split('/');
const params = {};
if (wildcardPaths.length === dataPaths.length) {
for (let idx = 0; idx < wildcardPaths.length; idx++) {
const wildcardPath = wildcardPaths[idx];
const name = wildcardPath.replace(/^{([^/{}]*)}$/, '$1');
if (name !== wildcardPath) {
// Wildcard parameter
params[name] = dataPaths[idx];
}
}
}
return params;
}

/** 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
Loading

0 comments on commit c1dd82b

Please sign in to comment.