Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 161 additions & 4 deletions src/remote-config/remote-config-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,180 @@
* limitations under the License.
*/

import { FirebaseRemoteConfigError } from './remote-config-utils';
import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request';
import { PrefixedFirebaseError } from '../utils/error';
import { FirebaseRemoteConfigError, RemoteConfigErrorCode } from './remote-config-utils';
import { FirebaseApp } from '../firebase-app';
import * as utils from '../utils/index';
import * as validator from '../utils/validator';

// Remote Config backend constants
const FIREBASE_REMOTE_CONFIG_V1_API = 'https://firebaseremoteconfig.googleapis.com/v1';
const FIREBASE_REMOTE_CONFIG_HEADERS = {
'X-Firebase-Client': 'fire-admin-node/<XXX_SDK_VERSION_XXX>',
// There is a known issue in which the ETag is not properly returned in cases where the request
// does not specify a compression type. Currently, it is required to include the header
// `Accept-Encoding: gzip` or equivalent in all requests.
// https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates
'Accept-Encoding': 'gzip',
};

enum ConditionDisplayColor {
UNSPECIFIED = "Unspecified",
BLUE = "Blue",
BROWN = "Brown",
CYAN = "Cyan",
DEEP_ORANGE = "Red Orange",
GREEN = "Green",
INDIGO = "Indigo",
LIME = "Lime",
ORANGE = "Orange",
PINK = "Pink",
PURPLE = "Purple",
TEAL = "Teal",
}

/** Interface representing a Remote Config parameter `value` in value options. */
export interface ExplicitParameterValue {
readonly value: string;
}

/** Interface representing a Remote Config parameter `useInAppDefault` in value options. */
export interface InAppDefaultValue {
readonly useInAppDefault: boolean;
}

export type RemoteConfigParameterValue = ExplicitParameterValue | InAppDefaultValue;

/** Interface representing a Remote Config parameter. */
export interface RemoteConfigParameter {
readonly defaultValue?: RemoteConfigParameterValue;
readonly conditionalValues?: { [key: string]: RemoteConfigParameterValue };
readonly description?: string;
}

interface RemoteConfigCondition {
name: string;
expression: string;
tagColor?: ConditionDisplayColor;
}

export interface RemoteConfigResponse {
readonly conditions?: RemoteConfigCondition[];
readonly parameters?: { [key: string]: RemoteConfigParameter };
readonly etag: string;
}

/**
* Class that facilitates sending requests to the Firebase Remote Config backend API.
*
* @private
*/
export class RemoteConfigApiClient {

constructor(app: FirebaseApp) {
private readonly httpClient: HttpClient;
private projectIdPrefix?: string;

constructor(private readonly app: FirebaseApp) {
if (!validator.isNonNullObject(app) || !('options' in app)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'First argument passed to admin.RemoteConfig() must be a valid Firebase app '
+ 'instance.');
'First argument passed to admin.remoteConfig() must be a valid Firebase app instance.');
}

this.httpClient = new AuthorizedHttpClient(app);
}

public getTemplate(): Promise<RemoteConfigResponse> {
return this.getUrl()
.then((url) => {
const request: HttpRequestConfig = {
method: 'GET',
url: `${url}/remoteConfig`,
headers: FIREBASE_REMOTE_CONFIG_HEADERS
};
return this.httpClient.send(request);
})
.then((resp) => {
if (!Object.prototype.hasOwnProperty.call(resp.headers, 'etag')) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'ETag header is not present in the server response.');
}
return {
conditions: resp.data.conditions,
parameters: resp.data.parameters,
etag: resp.headers['etag'],
};
})
.catch((err) => {
throw this.toFirebaseError(err);
});
}

private getUrl(): Promise<string> {
return this.getProjectIdPrefix()
.then((projectIdPrefix) => {
return `${FIREBASE_REMOTE_CONFIG_V1_API}/${projectIdPrefix}`;
});
}

private getProjectIdPrefix(): Promise<string> {
if (this.projectIdPrefix) {
return Promise.resolve(this.projectIdPrefix);
}

return utils.findProjectId(this.app)
.then((projectId) => {
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseRemoteConfigError(
'unknown-error',
'Failed to determine project ID. Initialize the SDK with service account credentials, or '
+ 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT '
+ 'environment variable.');
}

this.projectIdPrefix = `projects/${projectId}`;
return this.projectIdPrefix;
});
}

private toFirebaseError(err: HttpError): PrefixedFirebaseError {
if (err instanceof PrefixedFirebaseError) {
return err;
}

const response = err.response;
if (!response.isJson()) {
return new FirebaseRemoteConfigError(
'unknown-error',
`Unexpected response with status: ${response.status} and body: ${response.text}`);
}

const error: Error = (response.data as ErrorResponse).error || {};
let code: RemoteConfigErrorCode = 'unknown-error';
if (error.status && error.status in ERROR_CODE_MAPPING) {
code = ERROR_CODE_MAPPING[error.status];
}
const message = error.message || `Unknown server error: ${response.text}`;
return new FirebaseRemoteConfigError(code, message);
}
}

interface ErrorResponse {
error?: Error;
}

interface Error {
code?: number;
message?: string;
status?: string;
}

const ERROR_CODE_MAPPING: { [key: string]: RemoteConfigErrorCode } = {
INVALID_ARGUMENT: 'invalid-argument',
NOT_FOUND: 'not-found',
RESOURCE_EXHAUSTED: 'resource-exhausted',
UNAUTHENTICATED: 'authentication-error',
UNKNOWN: 'unknown-error',
};
2 changes: 2 additions & 0 deletions src/remote-config/remote-config-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ export type RemoteConfigErrorCode =
| 'invalid-argument'
| 'invalid-etag'
| 'invalid-template'
| 'not-found'
| 'obsolete-etag'
| 'permission-denied'
| 'resource-exhausted'
| 'unauthenticated'
| 'unknown-error';

Expand Down
94 changes: 66 additions & 28 deletions src/remote-config/remote-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,13 @@

import { FirebaseServiceInterface, FirebaseServiceInternalsInterface } from '../firebase-service';
import { FirebaseApp } from '../firebase-app';

/** Interface representing a Remote Config parameter. */
export interface RemoteConfigParameter {
key: string;
defaultValue?: string; // If `undefined`, the parameter uses the in-app default value
description?: string;

// A dictionary of {conditionName: value}
// `undefined` value sets `useInAppDefault` to `true` (equivalent to `No Value`)
conditionalValues?: { [name: string]: string | undefined };
}
import * as validator from '../utils/validator';
import { FirebaseRemoteConfigError } from './remote-config-utils';
import {
RemoteConfigApiClient,
RemoteConfigResponse,
RemoteConfigParameter
} from './remote-config-api-client';

/** Interface representing a Remote Config condition. */
export interface RemoteConfigCondition {
Expand Down Expand Up @@ -56,40 +52,82 @@ class RemoteConfigInternals implements FirebaseServiceInternalsInterface {
export class RemoteConfig implements FirebaseServiceInterface {
public readonly INTERNAL: RemoteConfigInternals = new RemoteConfigInternals();

private readonly client: RemoteConfigApiClient;

/**
* @param {FirebaseApp} app The app for this RemoteConfig service.
* @constructor
*/
constructor(readonly app: FirebaseApp) { }
constructor(readonly app: FirebaseApp) {
this.client = new RemoteConfigApiClient(app);
}

/**
* Gets the current active version of the Remote Config template of the project.
*
* @return {Promise<RemoteConfigTemplate>} A Promise that fulfills when the template is available.
*/
public getTemplate(): Promise<RemoteConfigTemplate> {
return this.client.getTemplate()
.then((templateResponse) => {
return new RemoteConfigTemplate(templateResponse);
});
}
}

/**
* Remote Config template class.
*/
export class RemoteConfigTemplate {

public parameters: RemoteConfigParameter[];
public parameters: { [key: string]: RemoteConfigParameter };
public conditions: RemoteConfigCondition[];
private readonly eTagInternal: string;
private readonly etagInternal: string;

/**
* Gets the ETag of the template.
*
* @return {string} The ETag of the Remote Config template.
*/
get eTag(): string {
return this.eTagInternal;
constructor(config: RemoteConfigResponse) {
if (!validator.isNonNullObject(config) ||
!validator.isNonEmptyString(config.etag)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
`Invalid Remote Config template response: ${JSON.stringify(config)}`);
}

this.etagInternal = config.etag;

if (typeof config.parameters !== 'undefined') {
if (!validator.isNonNullObject(config.parameters)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
`Remote Config parameters must be a non-null object`);
}
this.parameters = config.parameters;
} else {
this.parameters = {};
}

if (typeof config.conditions !== 'undefined') {
if (!validator.isArray(config.conditions)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
`Remote Config conditions must be an array`);
}
this.conditions = config.conditions.map(p => ({
name: p.name,
expression: p.expression,
color: p.tagColor
}));
} else {
this.conditions = [];
}
}

/**
* Find an existing Remote Config parameter by key.
*
* @param {string} key The key of the Remote Config parameter.
* Gets the ETag of the template.
*
* @return {RemoteConfigParameter} The Remote Config parameter with the provided key.
* @return {string} The ETag of the Remote Config template.
*/
public getParameter(key: string): RemoteConfigParameter | undefined {
return this.parameters.find((p) => p.key === key);
get etag(): string {
return this.etagInternal;
}

/**
Expand All @@ -108,7 +146,7 @@ export class RemoteConfigTemplate {
return {
parameters: this.parameters,
conditions: this.conditions,
eTag: this.eTag,
etag: this.etag,
};
}
}
4 changes: 4 additions & 0 deletions test/unit/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ import './project-management/ios-app.spec';
// SecurityRules
import './security-rules/security-rules.spec';
import './security-rules/security-rules-api-client.spec';

// RemoteConfig
import './remote-config/remote-config.spec';
import './remote-config/remote-config-api-client.spec';
Loading