Skip to content

Commit 4ddd6a8

Browse files
kjelkoKevin Elko
authored andcommitted
Adds support for exporting a serialized config response from the RC server side SDK. (#2829)
* add serialization method to server side remote config * update with latest changes * restructure as a class, add documentation * more tests for server config * run apidocs * Refine api and rerun docs * add back the newline * Apply suggestions from review * Make eTag optional in fetch response * rerun doc generator --------- Co-authored-by: Kevin Elko <[email protected]>
1 parent ebd0d60 commit 4ddd6a8

File tree

5 files changed

+223
-12
lines changed

5 files changed

+223
-12
lines changed

etc/firebase-admin.remote-config.api.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ export interface ExplicitParameterValue {
5252
value: string;
5353
}
5454

55+
// @public
56+
export interface FetchResponseData {
57+
config?: {
58+
[key: string]: string;
59+
};
60+
eTag?: string;
61+
status: number;
62+
}
63+
5564
// Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts
5665
//
5766
// @public
@@ -162,6 +171,13 @@ export interface RemoteConfigCondition {
162171
tagColor?: TagColor;
163172
}
164173

174+
// @public
175+
export class RemoteConfigFetchResponse {
176+
constructor(app: App, serverConfig: ServerConfig, requestEtag?: string);
177+
// (undocumented)
178+
toJSON(): FetchResponseData;
179+
}
180+
165181
// @public
166182
export interface RemoteConfigParameter {
167183
conditionalValues?: {
@@ -205,6 +221,9 @@ export interface RemoteConfigUser {
205221

206222
// @public
207223
export interface ServerConfig {
224+
getAll(): {
225+
[key: string]: Value;
226+
};
208227
getBoolean(key: string): boolean;
209228
getNumber(key: string): number;
210229
getString(key: string): string;

src/remote-config/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
DefaultConfig,
3232
EvaluationContext,
3333
ExplicitParameterValue,
34+
FetchResponseData,
3435
GetServerTemplateOptions,
3536
InAppDefaultValue,
3637
InitServerTemplateOptions,
@@ -60,7 +61,7 @@ export {
6061
ValueSource,
6162
Version,
6263
} from './remote-config-api';
63-
export { RemoteConfig } from './remote-config';
64+
export { RemoteConfig, RemoteConfigFetchResponse } from './remote-config';
6465

6566
/**
6667
* Gets the {@link RemoteConfig} service for the default app or a given app.

src/remote-config/remote-config-api.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export enum CustomSignalOperator {
236236
/**
237237
* Matches a numeric value less than or equal to the target value.
238238
*/
239-
NUMERIC_LESS_EQUAL ='NUMERIC_LESS_EQUAL',
239+
NUMERIC_LESS_EQUAL = 'NUMERIC_LESS_EQUAL',
240240

241241
/**
242242
* Matches a numeric value equal to the target value.
@@ -537,7 +537,7 @@ export interface ServerTemplate {
537537
/**
538538
* Generic map of developer-defined signals used as evaluation input signals.
539539
*/
540-
export type UserProvidedSignals = {[key: string]: string|number};
540+
export type UserProvidedSignals = { [key: string]: string | number };
541541

542542
/**
543543
* Predefined template evaluation input signals.
@@ -727,6 +727,40 @@ export interface ServerConfig {
727727
* @returns The value for the given key.
728728
*/
729729
getValue(key: string): Value;
730+
731+
/**
732+
* Returns all config values.
733+
*
734+
* @returns A map of all config keys to their values.
735+
*/
736+
getAll(): { [key: string]: Value }
737+
}
738+
739+
/**
740+
* JSON-serializable representation of evaluated config values. This can be consumed by
741+
* Remote Config web client SDKs.
742+
*/
743+
export interface FetchResponseData {
744+
/**
745+
* The HTTP status, which is useful for differentiating success responses with data from
746+
* those without.
747+
*
748+
* This use of 200 and 304 response codes is consistent with Remote Config's server
749+
* implementation.
750+
*/
751+
status: number;
752+
753+
/**
754+
* Defines the ETag response header value. Only defined for 200 and 304 responses.
755+
*
756+
* This is consistent with Remote Config's server eTag implementation.
757+
*/
758+
eTag?: string;
759+
760+
/**
761+
* Defines the map of parameters returned as "entries" in the fetch response body.
762+
*/
763+
config?: { [key: string]: string };
730764
}
731765

732766
/**

src/remote-config/remote-config.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { App } from '../app';
18+
import * as utils from '../utils/index';
1819
import * as validator from '../utils/validator';
1920
import { FirebaseRemoteConfigError, RemoteConfigApiClient } from './remote-config-api-client-internal';
2021
import { ConditionEvaluator } from './condition-evaluator-internal';
@@ -41,6 +42,7 @@ import {
4142
GetServerTemplateOptions,
4243
InitServerTemplateOptions,
4344
ServerTemplateDataType,
45+
FetchResponseData,
4446
} from './remote-config-api';
4547

4648
/**
@@ -298,7 +300,7 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate {
298300
*/
299301
class ServerTemplateImpl implements ServerTemplate {
300302
private cache: ServerTemplateData;
301-
private stringifiedDefaultConfig: {[key: string]: string} = {};
303+
private stringifiedDefaultConfig: { [key: string]: string } = {};
302304

303305
constructor(
304306
private readonly apiClient: RemoteConfigApiClient,
@@ -425,7 +427,7 @@ class ServerTemplateImpl implements ServerTemplate {
425427
class ServerConfigImpl implements ServerConfig {
426428
constructor(
427429
private readonly configValues: { [key: string]: Value },
428-
){}
430+
) { }
429431
getBoolean(key: string): boolean {
430432
return this.getValue(key).asBoolean();
431433
}
@@ -438,6 +440,9 @@ class ServerConfigImpl implements ServerConfig {
438440
getValue(key: string): Value {
439441
return this.configValues[key] || new ValueImpl('static');
440442
}
443+
getAll(): { [key: string]: Value } {
444+
return { ...this.configValues };
445+
}
441446
}
442447

443448
/**
@@ -613,3 +618,62 @@ class VersionImpl implements Version {
613618
return validator.isNonEmptyString(timestamp) && (new Date(timestamp)).getTime() > 0;
614619
}
615620
}
621+
622+
const HTTP_NOT_MODIFIED = 304;
623+
const HTTP_OK = 200;
624+
625+
/**
626+
* Represents a fetch response that can be used to interact with RC's client SDK.
627+
*/
628+
export class RemoteConfigFetchResponse {
629+
private response: FetchResponseData;
630+
631+
/**
632+
* @param app - The app for this RemoteConfig service.
633+
* @param serverConfig - The server config for which to generate a fetch response.
634+
* @param requestEtag - A request eTag with which to compare the current response.
635+
*/
636+
constructor(app: App, serverConfig: ServerConfig, requestEtag?: string) {
637+
const config: { [key: string]: string } = {};
638+
for (const [param, value] of Object.entries(serverConfig.getAll())) {
639+
config[param] = value.asString();
640+
}
641+
642+
const currentEtag = this.processEtag(config, app);
643+
644+
if (currentEtag === requestEtag) {
645+
this.response = {
646+
status: HTTP_NOT_MODIFIED,
647+
eTag: currentEtag,
648+
};
649+
} else {
650+
this.response = {
651+
status: HTTP_OK,
652+
eTag: currentEtag,
653+
config,
654+
}
655+
}
656+
}
657+
658+
/**
659+
* @returns JSON representation of the fetch response that can be consumed
660+
* by the RC client SDK.
661+
*/
662+
public toJSON(): FetchResponseData {
663+
return this.response;
664+
}
665+
666+
private processEtag(config: { [key: string]: string }, app: App): string {
667+
const configJson = JSON.stringify(config);
668+
let hash = 0;
669+
// Mimics Java's `String.hashCode()` which is used in RC's servers.
670+
for (let i = 0; i < configJson.length; i++) {
671+
const char = configJson.charCodeAt(i);
672+
hash = (hash << 5) - hash + char;
673+
hash |= 0;
674+
}
675+
const projectId = utils.getExplicitProjectId(app);
676+
const parts = ['etag', projectId, 'firebase-server', 'fetch', hash];
677+
return parts.filter(a => !!a).join('-');
678+
}
679+
}

test/unit/remote-config/remote-config.spec.ts

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
RemoteConfigCondition,
2727
TagColor,
2828
ListVersionsResult,
29+
RemoteConfigFetchResponse,
2930
} from '../../../src/remote-config/index';
3031
import { FirebaseApp } from '../../../src/app/firebase-app';
3132
import * as mocks from '../../resources/mocks';
@@ -1001,14 +1002,14 @@ describe('RemoteConfig', () => {
10011002
describe('should throw error if there are any JSON or tempalte parsing errors', () => {
10021003
const INVALID_PARAMETERS: any[] = [null, '', 'abc', 1, true, []];
10031004
const INVALID_CONDITIONS: any[] = [null, '', 'abc', 1, true, {}];
1004-
1005+
10051006
let sourceTemplate = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE);
10061007
const jsonString = '{invalidJson: null}';
10071008
it('should throw if template is an invalid JSON', () => {
10081009
expect(() => remoteConfig.initServerTemplate({ template: jsonString }))
10091010
.to.throw(/Failed to parse the JSON string: ([\D\w]*)\./);
10101011
});
1011-
1012+
10121013
INVALID_PARAMETERS.forEach((invalidParameter) => {
10131014
sourceTemplate.parameters = invalidParameter;
10141015
const jsonString = JSON.stringify(sourceTemplate);
@@ -1017,7 +1018,7 @@ describe('RemoteConfig', () => {
10171018
.to.throw('Remote Config parameters must be a non-null object');
10181019
});
10191020
});
1020-
1021+
10211022
sourceTemplate = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE);
10221023
INVALID_CONDITIONS.forEach((invalidConditions) => {
10231024
sourceTemplate.conditions = invalidConditions;
@@ -1292,20 +1293,53 @@ describe('RemoteConfig', () => {
12921293
// Note the static source is set in the getValue() method, but the other sources
12931294
// are set in the evaluate() method, so these tests span a couple layers.
12941295
describe('ServerConfig', () => {
1296+
describe('getAll', () => {
1297+
it('should return all values', () => {
1298+
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
1299+
templateData.parameters = {
1300+
dog_type: {
1301+
defaultValue: {
1302+
value: 'pug'
1303+
}
1304+
},
1305+
dog_type_enabled: {
1306+
defaultValue: {
1307+
value: 'true'
1308+
}
1309+
},
1310+
dog_age: {
1311+
defaultValue: {
1312+
value: '22'
1313+
}
1314+
},
1315+
dog_use_inapp_default: {
1316+
defaultValue: {
1317+
useInAppDefault: true
1318+
}
1319+
},
1320+
};
1321+
const template = remoteConfig.initServerTemplate({ template: templateData });
1322+
const config = template.evaluate().getAll();
1323+
expect(Object.keys(config)).deep.equal(['dog_type', 'dog_type_enabled', 'dog_age']);
1324+
expect(config['dog_type'].asString()).to.equal('pug');
1325+
expect(config['dog_type_enabled'].asBoolean()).to.equal(true);
1326+
expect(config['dog_age'].asNumber()).to.equal(22);
1327+
});
1328+
});
1329+
12951330
describe('getValue', () => {
12961331
it('should return static when default and remote are not defined', () => {
12971332
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
12981333
// Omits remote parameter values.
12991334
templateData.parameters = {
1300-
};
1301-
// Omits in-app default values.
1335+
}
13021336
const template = remoteConfig.initServerTemplate({ template: templateData });
13031337
const config = template.evaluate();
13041338
const value = config.getValue('dog_type');
13051339
expect(value.asString()).to.equal('');
13061340
expect(value.getSource()).to.equal('static');
13071341
});
1308-
1342+
13091343
it('should return default value when it is defined', () => {
13101344
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
13111345
// Omits remote parameter values.
@@ -1323,7 +1357,7 @@ describe('RemoteConfig', () => {
13231357
expect(value.asString()).to.equal('shiba');
13241358
expect(value.getSource()).to.equal('default');
13251359
});
1326-
1360+
13271361
it('should return remote value when it is defined', () => {
13281362
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
13291363
// Defines remote parameter values.
@@ -1391,6 +1425,65 @@ describe('RemoteConfig', () => {
13911425
});
13921426
});
13931427

1428+
describe('RemoteConfigFetchResponse', () => {
1429+
it('should return a 200 response when supplied with no etag', () => {
1430+
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
1431+
// Defines remote parameter values.
1432+
templateData.parameters = {
1433+
dog_type: {
1434+
defaultValue: {
1435+
value: 'beagle'
1436+
}
1437+
}
1438+
};
1439+
const template = remoteConfig.initServerTemplate({ template: templateData });
1440+
const fetchResponse = new RemoteConfigFetchResponse(mockApp, template.evaluate());
1441+
expect(fetchResponse.toJSON()).deep.equals({
1442+
status: 200,
1443+
eTag: 'etag-project_id-firebase-server-fetch--2039110429',
1444+
config: { 'dog_type': 'beagle' }
1445+
});
1446+
});
1447+
1448+
it('should return a 200 response when supplied with a stale etag', () => {
1449+
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
1450+
// Defines remote parameter values.
1451+
templateData.parameters = {
1452+
dog_type: {
1453+
defaultValue: {
1454+
value: 'beagle'
1455+
}
1456+
}
1457+
};
1458+
const template = remoteConfig.initServerTemplate({ template: templateData });
1459+
const fetchResponse = new RemoteConfigFetchResponse(mockApp, template.evaluate(), 'fake-etag');
1460+
expect(fetchResponse.toJSON()).deep.equals({
1461+
status: 200,
1462+
eTag: 'etag-project_id-firebase-server-fetch--2039110429',
1463+
config: { 'dog_type': 'beagle' }
1464+
});
1465+
});
1466+
1467+
it('should return a 304 repsonse with matching etag', () => {
1468+
const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData;
1469+
// Defines remote parameter values.
1470+
templateData.parameters = {
1471+
dog_type: {
1472+
defaultValue: {
1473+
value: 'beagle'
1474+
}
1475+
}
1476+
};
1477+
const template = remoteConfig.initServerTemplate({ template: templateData });
1478+
const fetchResponse = new RemoteConfigFetchResponse(
1479+
mockApp, template.evaluate(), 'etag-project_id-firebase-server-fetch--2039110429');
1480+
expect(fetchResponse.toJSON()).deep.equals({
1481+
status: 304,
1482+
eTag: 'etag-project_id-firebase-server-fetch--2039110429'
1483+
});
1484+
});
1485+
});
1486+
13941487
function runInvalidResponseTests(rcOperation: () => Promise<RemoteConfigTemplate>,
13951488
operationName: any): void {
13961489
it('should propagate API errors', () => {

0 commit comments

Comments
 (0)