diff --git a/etc/firebase-admin.remote-config.api.md b/etc/firebase-admin.remote-config.api.md index 68d760468c..cf7eb32b93 100644 --- a/etc/firebase-admin.remote-config.api.md +++ b/etc/firebase-admin.remote-config.api.md @@ -52,6 +52,15 @@ export interface ExplicitParameterValue { value: string; } +// @public +export interface FetchResponseData { + config?: { + [key: string]: string; + }; + eTag?: string; + status: number; +} + // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts // // @public @@ -162,6 +171,13 @@ export interface RemoteConfigCondition { tagColor?: TagColor; } +// @public +export class RemoteConfigFetchResponse { + constructor(app: App, serverConfig: ServerConfig, requestEtag?: string); + // (undocumented) + toJSON(): FetchResponseData; +} + // @public export interface RemoteConfigParameter { conditionalValues?: { @@ -205,6 +221,9 @@ export interface RemoteConfigUser { // @public export interface ServerConfig { + getAll(): { + [key: string]: Value; + }; getBoolean(key: string): boolean; getNumber(key: string): number; getString(key: string): string; diff --git a/src/remote-config/index.ts b/src/remote-config/index.ts index 7b66c44ee9..42a2573227 100644 --- a/src/remote-config/index.ts +++ b/src/remote-config/index.ts @@ -31,6 +31,7 @@ export { DefaultConfig, EvaluationContext, ExplicitParameterValue, + FetchResponseData, GetServerTemplateOptions, InAppDefaultValue, InitServerTemplateOptions, @@ -60,7 +61,7 @@ export { ValueSource, Version, } from './remote-config-api'; -export { RemoteConfig } from './remote-config'; +export { RemoteConfig, RemoteConfigFetchResponse } from './remote-config'; /** * Gets the {@link RemoteConfig} service for the default app or a given app. diff --git a/src/remote-config/remote-config-api.ts b/src/remote-config/remote-config-api.ts index 4dfa1da023..d315572192 100644 --- a/src/remote-config/remote-config-api.ts +++ b/src/remote-config/remote-config-api.ts @@ -236,7 +236,7 @@ export enum CustomSignalOperator { /** * Matches a numeric value less than or equal to the target value. */ - NUMERIC_LESS_EQUAL ='NUMERIC_LESS_EQUAL', + NUMERIC_LESS_EQUAL = 'NUMERIC_LESS_EQUAL', /** * Matches a numeric value equal to the target value. @@ -537,7 +537,7 @@ export interface ServerTemplate { /** * Generic map of developer-defined signals used as evaluation input signals. */ -export type UserProvidedSignals = {[key: string]: string|number}; +export type UserProvidedSignals = { [key: string]: string | number }; /** * Predefined template evaluation input signals. @@ -727,6 +727,40 @@ export interface ServerConfig { * @returns The value for the given key. */ getValue(key: string): Value; + + /** + * Returns all config values. + * + * @returns A map of all config keys to their values. + */ + getAll(): { [key: string]: Value } +} + +/** + * JSON-serializable representation of evaluated config values. This can be consumed by + * Remote Config web client SDKs. + */ +export interface FetchResponseData { + /** + * The HTTP status, which is useful for differentiating success responses with data from + * those without. + * + * This use of 200 and 304 response codes is consistent with Remote Config's server + * implementation. + */ + status: number; + + /** + * Defines the ETag response header value. Only defined for 200 and 304 responses. + * + * This is consistent with Remote Config's server eTag implementation. + */ + eTag?: string; + + /** + * Defines the map of parameters returned as "entries" in the fetch response body. + */ + config?: { [key: string]: string }; } /** diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index c529501315..a984d67e1f 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -15,6 +15,7 @@ */ import { App } from '../app'; +import * as utils from '../utils/index'; import * as validator from '../utils/validator'; import { FirebaseRemoteConfigError, RemoteConfigApiClient } from './remote-config-api-client-internal'; import { ConditionEvaluator } from './condition-evaluator-internal'; @@ -41,6 +42,7 @@ import { GetServerTemplateOptions, InitServerTemplateOptions, ServerTemplateDataType, + FetchResponseData, } from './remote-config-api'; /** @@ -298,7 +300,7 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate { */ class ServerTemplateImpl implements ServerTemplate { private cache: ServerTemplateData; - private stringifiedDefaultConfig: {[key: string]: string} = {}; + private stringifiedDefaultConfig: { [key: string]: string } = {}; constructor( private readonly apiClient: RemoteConfigApiClient, @@ -425,7 +427,7 @@ class ServerTemplateImpl implements ServerTemplate { class ServerConfigImpl implements ServerConfig { constructor( private readonly configValues: { [key: string]: Value }, - ){} + ) { } getBoolean(key: string): boolean { return this.getValue(key).asBoolean(); } @@ -438,6 +440,9 @@ class ServerConfigImpl implements ServerConfig { getValue(key: string): Value { return this.configValues[key] || new ValueImpl('static'); } + getAll(): { [key: string]: Value } { + return { ...this.configValues }; + } } /** @@ -613,3 +618,62 @@ class VersionImpl implements Version { return validator.isNonEmptyString(timestamp) && (new Date(timestamp)).getTime() > 0; } } + +const HTTP_NOT_MODIFIED = 304; +const HTTP_OK = 200; + +/** + * Represents a fetch response that can be used to interact with RC's client SDK. + */ +export class RemoteConfigFetchResponse { + private response: FetchResponseData; + + /** + * @param app - The app for this RemoteConfig service. + * @param serverConfig - The server config for which to generate a fetch response. + * @param requestEtag - A request eTag with which to compare the current response. + */ + constructor(app: App, serverConfig: ServerConfig, requestEtag?: string) { + const config: { [key: string]: string } = {}; + for (const [param, value] of Object.entries(serverConfig.getAll())) { + config[param] = value.asString(); + } + + const currentEtag = this.processEtag(config, app); + + if (currentEtag === requestEtag) { + this.response = { + status: HTTP_NOT_MODIFIED, + eTag: currentEtag, + }; + } else { + this.response = { + status: HTTP_OK, + eTag: currentEtag, + config, + } + } + } + + /** + * @returns JSON representation of the fetch response that can be consumed + * by the RC client SDK. + */ + public toJSON(): FetchResponseData { + return this.response; + } + + private processEtag(config: { [key: string]: string }, app: App): string { + const configJson = JSON.stringify(config); + let hash = 0; + // Mimics Java's `String.hashCode()` which is used in RC's servers. + for (let i = 0; i < configJson.length; i++) { + const char = configJson.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; + } + const projectId = utils.getExplicitProjectId(app); + const parts = ['etag', projectId, 'firebase-server', 'fetch', hash]; + return parts.filter(a => !!a).join('-'); + } +} diff --git a/test/unit/remote-config/remote-config.spec.ts b/test/unit/remote-config/remote-config.spec.ts index 526dc0699e..976266e5ab 100644 --- a/test/unit/remote-config/remote-config.spec.ts +++ b/test/unit/remote-config/remote-config.spec.ts @@ -26,6 +26,7 @@ import { RemoteConfigCondition, TagColor, ListVersionsResult, + RemoteConfigFetchResponse, } from '../../../src/remote-config/index'; import { FirebaseApp } from '../../../src/app/firebase-app'; import * as mocks from '../../resources/mocks'; @@ -1001,14 +1002,14 @@ describe('RemoteConfig', () => { describe('should throw error if there are any JSON or tempalte parsing errors', () => { const INVALID_PARAMETERS: any[] = [null, '', 'abc', 1, true, []]; const INVALID_CONDITIONS: any[] = [null, '', 'abc', 1, true, {}]; - + let sourceTemplate = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); const jsonString = '{invalidJson: null}'; it('should throw if template is an invalid JSON', () => { expect(() => remoteConfig.initServerTemplate({ template: jsonString })) .to.throw(/Failed to parse the JSON string: ([\D\w]*)\./); }); - + INVALID_PARAMETERS.forEach((invalidParameter) => { sourceTemplate.parameters = invalidParameter; const jsonString = JSON.stringify(sourceTemplate); @@ -1017,7 +1018,7 @@ describe('RemoteConfig', () => { .to.throw('Remote Config parameters must be a non-null object'); }); }); - + sourceTemplate = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE); INVALID_CONDITIONS.forEach((invalidConditions) => { sourceTemplate.conditions = invalidConditions; @@ -1292,20 +1293,53 @@ describe('RemoteConfig', () => { // Note the static source is set in the getValue() method, but the other sources // are set in the evaluate() method, so these tests span a couple layers. describe('ServerConfig', () => { + describe('getAll', () => { + it('should return all values', () => { + const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; + templateData.parameters = { + dog_type: { + defaultValue: { + value: 'pug' + } + }, + dog_type_enabled: { + defaultValue: { + value: 'true' + } + }, + dog_age: { + defaultValue: { + value: '22' + } + }, + dog_use_inapp_default: { + defaultValue: { + useInAppDefault: true + } + }, + }; + const template = remoteConfig.initServerTemplate({ template: templateData }); + const config = template.evaluate().getAll(); + expect(Object.keys(config)).deep.equal(['dog_type', 'dog_type_enabled', 'dog_age']); + expect(config['dog_type'].asString()).to.equal('pug'); + expect(config['dog_type_enabled'].asBoolean()).to.equal(true); + expect(config['dog_age'].asNumber()).to.equal(22); + }); + }); + describe('getValue', () => { it('should return static when default and remote are not defined', () => { const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; // Omits remote parameter values. templateData.parameters = { - }; - // Omits in-app default values. + } const template = remoteConfig.initServerTemplate({ template: templateData }); const config = template.evaluate(); const value = config.getValue('dog_type'); expect(value.asString()).to.equal(''); expect(value.getSource()).to.equal('static'); }); - + it('should return default value when it is defined', () => { const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; // Omits remote parameter values. @@ -1323,7 +1357,7 @@ describe('RemoteConfig', () => { expect(value.asString()).to.equal('shiba'); expect(value.getSource()).to.equal('default'); }); - + it('should return remote value when it is defined', () => { const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; // Defines remote parameter values. @@ -1391,6 +1425,65 @@ describe('RemoteConfig', () => { }); }); + describe('RemoteConfigFetchResponse', () => { + it('should return a 200 response when supplied with no etag', () => { + const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; + // Defines remote parameter values. + templateData.parameters = { + dog_type: { + defaultValue: { + value: 'beagle' + } + } + }; + const template = remoteConfig.initServerTemplate({ template: templateData }); + const fetchResponse = new RemoteConfigFetchResponse(mockApp, template.evaluate()); + expect(fetchResponse.toJSON()).deep.equals({ + status: 200, + eTag: 'etag-project_id-firebase-server-fetch--2039110429', + config: { 'dog_type': 'beagle' } + }); + }); + + it('should return a 200 response when supplied with a stale etag', () => { + const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; + // Defines remote parameter values. + templateData.parameters = { + dog_type: { + defaultValue: { + value: 'beagle' + } + } + }; + const template = remoteConfig.initServerTemplate({ template: templateData }); + const fetchResponse = new RemoteConfigFetchResponse(mockApp, template.evaluate(), 'fake-etag'); + expect(fetchResponse.toJSON()).deep.equals({ + status: 200, + eTag: 'etag-project_id-firebase-server-fetch--2039110429', + config: { 'dog_type': 'beagle' } + }); + }); + + it('should return a 304 repsonse with matching etag', () => { + const templateData = deepCopy(SERVER_REMOTE_CONFIG_RESPONSE) as ServerTemplateData; + // Defines remote parameter values. + templateData.parameters = { + dog_type: { + defaultValue: { + value: 'beagle' + } + } + }; + const template = remoteConfig.initServerTemplate({ template: templateData }); + const fetchResponse = new RemoteConfigFetchResponse( + mockApp, template.evaluate(), 'etag-project_id-firebase-server-fetch--2039110429'); + expect(fetchResponse.toJSON()).deep.equals({ + status: 304, + eTag: 'etag-project_id-firebase-server-fetch--2039110429' + }); + }); + }); + function runInvalidResponseTests(rcOperation: () => Promise, operationName: any): void { it('should propagate API errors', () => {