Skip to content

Commit 968be74

Browse files
authored
Firebase Remote Config Functions SDK Integration (#215)
Adds a Remote Config event handler `firebase.remoteconfig.onUpdate()` that will be triggered whenever a Remote Config project is updated (i.e., when a publish or rollback occurs). The event handler takes in a `TemplateVersion` object as an argument, which contains metadata about the last update that affected a Remote Config project. The PR also contains corresponding unit test and integration tests. The integration test makes use of the Remote Config REST API: https://firebase.google.com/docs/remote-config/use-config-rest.
1 parent 42ec44d commit 968be74

File tree

8 files changed

+311
-2
lines changed

8 files changed

+311
-2
lines changed

integration_test/functions/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './database-tests';
1111
export * from './auth-tests';
1212
export * from './firestore-tests';
1313
export * from './https-tests';
14+
export * from './remoteConfig-tests';
1415
const numTests = Object.keys(exports).length; // Assumption: every exported function is its own test.
1516

1617
import 'firebase-functions'; // temporary shim until process.env.FIREBASE_CONFIG available natively in GCF(BUG 63586213)
@@ -86,6 +87,26 @@ export const integrationTests: any = functions
8687
.collection('tests')
8788
.doc(testId)
8889
.set({ test: testId }),
90+
// A Remote Config update to trigger the Remote Config tests.
91+
admin.credential
92+
.applicationDefault()
93+
.getAccessToken()
94+
.then((accessToken) => {
95+
const options = {
96+
hostname: 'firebaseremoteconfig.googleapis.com',
97+
path: `/v1/projects/${firebaseConfig.projectId}/remoteConfig`,
98+
method: 'PUT',
99+
headers: {
100+
Authorization: 'Bearer ' + accessToken.access_token,
101+
'Content-Type': 'application/json; UTF-8',
102+
'Accept-Encoding': 'gzip',
103+
'If-Match': '*',
104+
},
105+
};
106+
const request = https.request(options, resp => {});
107+
request.write(JSON.stringify({ version: { description: testId } }));
108+
request.end();
109+
}),
89110
// Invoke a callable HTTPS trigger.
90111
callHttpsTrigger('callableTests', { foo: 'bar', testId }),
91112
])
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as functions from 'firebase-functions';
2+
import { TestSuite, expectEq } from './testing';
3+
import TemplateVersion = functions.remoteConfig.TemplateVersion;
4+
5+
export const remoteConfigTests: any = functions.remoteConfig.onUpdate(
6+
(v, c) => {
7+
return new TestSuite<TemplateVersion>('remoteConfig onUpdate')
8+
.it('should have a project as resource', (version, context) =>
9+
expectEq(
10+
context.resource.name,
11+
`projects/${process.env.GCLOUD_PROJECT}`
12+
)
13+
)
14+
15+
.it('should have the correct eventType', (version, context) =>
16+
expectEq(context.eventType, 'google.firebase.remoteconfig.update')
17+
)
18+
19+
.it('should have an eventId', (version, context) => context.eventId)
20+
21+
.it('should have a timestamp', (version, context) => context.timestamp)
22+
23+
.it('should not have auth', (version, context) =>
24+
expectEq((context as any).auth, undefined)
25+
)
26+
27+
.run(v.description, v, c);
28+
}
29+
);

integration_test/run_tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function delete_all_functions {
5151
cd $DIR
5252
# Try to delete, if there are errors it is because the project is already empty,
5353
# in that case do nothing.
54-
firebase functions:delete callableTests createUserTests databaseTests deleteUserTests firestoreTests integrationTests pubsubTests --project=$PROJECT_ID -f || :
54+
firebase functions:delete callableTests createUserTests databaseTests deleteUserTests firestoreTests integrationTests pubsubTests remoteConfigTests --project=$PROJECT_ID -f || :
5555
announce "Project emptied."
5656
}
5757

spec/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ import './providers/database.spec';
4040
import './providers/firestore.spec';
4141
import './providers/https.spec';
4242
import './providers/pubsub.spec';
43+
import './providers/remoteConfig.spec';
4344
import './providers/storage.spec';
4445
import './providers/crashlytics.spec';

spec/providers/remoteConfig.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// The MIT License (MIT)
2+
//
3+
// Copyright (c) 2017 Firebase
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
import { expect } from 'chai';
23+
import * as admin from 'firebase-admin';
24+
import * as _ from 'lodash';
25+
26+
import { CloudFunction } from '../../src/cloud-functions';
27+
import * as functions from '../../src/index';
28+
import * as remoteConfig from '../../src/providers/remoteConfig';
29+
30+
describe('RemoteConfig Functions', () => {
31+
function constructVersion() {
32+
return {
33+
versionNumber: 1,
34+
updateTime: '2017-07-02T18:48:58.920638Z',
35+
updateUser: {
36+
name: 'Foo Bar',
37+
38+
},
39+
description: 'test description',
40+
updateOrigin: 'CONSOLE',
41+
updateType: 'INCREMENTAL_UPDATE',
42+
};
43+
}
44+
45+
function makeEvent(data: any, context?: { [key: string]: any }) {
46+
context = context || {};
47+
return {
48+
data: data,
49+
context: _.merge(
50+
{
51+
eventId: '123',
52+
timestamp: '2018-07-03T00:49:04.264Z',
53+
eventType: 'google.firebase.remoteconfig.update',
54+
resource: {
55+
name: 'projects/project1',
56+
service: 'service',
57+
},
58+
},
59+
context
60+
),
61+
};
62+
}
63+
64+
describe('#onUpdate', () => {
65+
function expectedTrigger() {
66+
return {
67+
eventTrigger: {
68+
resource: 'projects/project1',
69+
eventType: 'google.firebase.remoteconfig.update',
70+
service: 'firebaseremoteconfig.googleapis.com',
71+
},
72+
};
73+
}
74+
75+
before(() => {
76+
process.env.GCLOUD_PROJECT = 'project1';
77+
});
78+
79+
after(() => {
80+
delete process.env.GCLOUD_PROJECT;
81+
});
82+
83+
it('should have the correct trigger', () => {
84+
const cloudFunction = remoteConfig.onUpdate(() => null);
85+
expect(cloudFunction.__trigger).to.deep.equal(expectedTrigger());
86+
});
87+
88+
it('should allow both region and runtime options to be set', () => {
89+
const cloudFunction = functions
90+
.region('my-region')
91+
.runWith({
92+
timeoutSeconds: 90,
93+
memory: '256MB',
94+
})
95+
.remoteConfig.onUpdate(() => null);
96+
97+
expect(cloudFunction.__trigger.regions).to.deep.equal(['my-region']);
98+
expect(cloudFunction.__trigger.availableMemoryMb).to.deep.equal(256);
99+
expect(cloudFunction.__trigger.timeout).to.deep.equal('90s');
100+
});
101+
});
102+
103+
describe('unwraps TemplateVersion', () => {
104+
let cloudFunctionUpdate: CloudFunction<remoteConfig.TemplateVersion>;
105+
let event: any;
106+
before(() => {
107+
process.env.GCLOUD_PROJECT = 'project1';
108+
cloudFunctionUpdate = remoteConfig.onUpdate(
109+
(version: remoteConfig.TemplateVersion) => version
110+
);
111+
event = {
112+
data: constructVersion(),
113+
};
114+
});
115+
116+
after(() => {
117+
delete process.env.GCLOUD_PROJECT;
118+
});
119+
120+
it('should unwrap the version in the event', () => {
121+
return Promise.all([
122+
cloudFunctionUpdate(event).then((data: any) => {
123+
expect(data).to.deep.equal(constructVersion());
124+
}),
125+
]);
126+
});
127+
});
128+
});

src/function-builder.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ import * as database from './providers/database';
3030
import * as firestore from './providers/firestore';
3131
import * as https from './providers/https';
3232
import * as pubsub from './providers/pubsub';
33+
import * as remoteConfig from './providers/remoteConfig';
3334
import * as storage from './providers/storage';
34-
import { HttpsFunction } from './cloud-functions';
35+
import { CloudFunction, EventContext, HttpsFunction } from './cloud-functions';
3536

3637
/**
3738
* Configure the regions that the function is deployed to.
@@ -212,6 +213,26 @@ export class FunctionBuilder {
212213
};
213214
}
214215

216+
get remoteConfig() {
217+
return {
218+
/**
219+
* Handle all updates (including rollbacks) that affect a Remote Config
220+
* project.
221+
* @param handler A function that takes the updated Remote Config template
222+
* version metadata as an argument.
223+
*/
224+
onUpdate: (
225+
handler: (
226+
version: remoteConfig.TemplateVersion,
227+
context: EventContext
228+
) => PromiseLike<any> | any
229+
) =>
230+
remoteConfig._onUpdateWithOpts(handler, this.options) as CloudFunction<
231+
remoteConfig.TemplateVersion
232+
>,
233+
};
234+
}
235+
215236
get storage() {
216237
return {
217238
/**

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as database from './providers/database';
2929
import * as firestore from './providers/firestore';
3030
import * as https from './providers/https';
3131
import * as pubsub from './providers/pubsub';
32+
import * as remoteConfig from './providers/remoteConfig';
3233
import * as storage from './providers/storage';
3334
import { firebaseConfig } from './config';
3435

@@ -40,6 +41,7 @@ export {
4041
firestore,
4142
https,
4243
pubsub,
44+
remoteConfig,
4345
storage,
4446
};
4547

src/providers/remoteConfig.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// The MIT License (MIT)
2+
//
3+
// Copyright (c) 2018 Firebase
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the 'Software'), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
import * as _ from 'lodash';
24+
25+
import {
26+
CloudFunction,
27+
Event,
28+
EventContext,
29+
makeCloudFunction,
30+
} from '../cloud-functions';
31+
import { DeploymentOptions } from '../function-builder';
32+
33+
/** @internal */
34+
export const provider = 'google.firebase.remoteconfig';
35+
/** @internal */
36+
export const service = 'firebaseremoteconfig.googleapis.com';
37+
38+
/**
39+
* Handle all updates (including rollbacks) that affect a Remote Config project.
40+
* @param handler A function that takes the updated Remote Config template
41+
* version metadata as an argument.
42+
*/
43+
export function onUpdate(
44+
handler: (
45+
version: TemplateVersion,
46+
context: EventContext
47+
) => PromiseLike<any> | any
48+
): CloudFunction<TemplateVersion> {
49+
return _onUpdateWithOpts(handler, {});
50+
}
51+
52+
/** @internal */
53+
export function _onUpdateWithOpts(
54+
handler: (
55+
version: TemplateVersion,
56+
context: EventContext
57+
) => PromiseLike<any> | any,
58+
opts: DeploymentOptions
59+
): CloudFunction<TemplateVersion> {
60+
if (!process.env.GCLOUD_PROJECT) {
61+
throw new Error('process.env.GCLOUD_PROJECT is not set.');
62+
}
63+
return makeCloudFunction({
64+
handler,
65+
provider,
66+
service,
67+
triggerResource: () => `projects/${process.env.GCLOUD_PROJECT}`,
68+
eventType: 'update',
69+
opts: opts,
70+
});
71+
}
72+
73+
/**
74+
* Interface representing a Remote Config template version metadata object that
75+
* was emitted when the project was updated.
76+
*/
77+
export interface TemplateVersion {
78+
/** The version number of the updated Remote Config template. */
79+
versionNumber: number;
80+
81+
/** When the template was updated in format (ISO8601 timestamp). */
82+
updateTime: string;
83+
84+
/** Metadata about the account that performed the update. */
85+
updateUser: RemoteConfigUser;
86+
87+
/** A description associated with the particular Remote Config template. */
88+
description: string;
89+
90+
/** The origin of the caller. */
91+
updateOrigin: string;
92+
93+
/** The type of update action that was performed. */
94+
updateType: string;
95+
96+
/**
97+
* The version number of the Remote Config template that was rolled back to,
98+
* if the update was a rollback.
99+
*/
100+
rollbackSource?: number;
101+
}
102+
103+
export interface RemoteConfigUser {
104+
name?: string;
105+
email: string;
106+
imageUrl?: string;
107+
}

0 commit comments

Comments
 (0)