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
70 changes: 55 additions & 15 deletions src/remote-config/remote-config-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ const FIREBASE_REMOTE_CONFIG_HEADERS = {
'Accept-Encoding': 'gzip',
};

enum ConditionDisplayColor {
UNSPECIFIED = "Unspecified",
export enum RemoteConfigConditionDisplayColor {
BLUE = "Blue",
BROWN = "Brown",
CYAN = "Cyan",
Expand All @@ -49,32 +48,34 @@ enum ConditionDisplayColor {

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

/** Interface representing a Remote Config parameter `useInAppDefault` in value options. */
export interface InAppDefaultValue {
readonly useInAppDefault: boolean;
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;
defaultValue?: RemoteConfigParameterValue;
conditionalValues?: { [key: string]: RemoteConfigParameterValue };
description?: string;
}

interface RemoteConfigCondition {
/** Interface representing a Remote Config condition. */
export interface RemoteConfigCondition {
name: string;
expression: string;
tagColor?: ConditionDisplayColor;
tagColor?: RemoteConfigConditionDisplayColor;
}

export interface RemoteConfigResponse {
readonly conditions?: RemoteConfigCondition[];
readonly parameters?: { [key: string]: RemoteConfigParameter };
/** Interface representing a Remote Config template. */
export interface RemoteConfigTemplate {
conditions: RemoteConfigCondition[];
parameters: { [key: string]: RemoteConfigParameter };
readonly etag: string;
}

Expand All @@ -98,7 +99,7 @@ export class RemoteConfigApiClient {
this.httpClient = new AuthorizedHttpClient(app);
}

public getTemplate(): Promise<RemoteConfigResponse> {
public getTemplate(): Promise<RemoteConfigTemplate> {
return this.getUrl()
.then((url) => {
const request: HttpRequestConfig = {
Expand All @@ -109,7 +110,7 @@ export class RemoteConfigApiClient {
return this.httpClient.send(request);
})
.then((resp) => {
if (!Object.prototype.hasOwnProperty.call(resp.headers, 'etag')) {
if (!validator.isNonEmptyString(resp.headers['etag'])) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'ETag header is not present in the server response.');
Expand All @@ -125,6 +126,40 @@ export class RemoteConfigApiClient {
});
}

public validateTemplate(template: RemoteConfigTemplate): Promise<RemoteConfigTemplate> {
return this.getUrl()
.then((url) => {
const request: HttpRequestConfig = {
method: 'PUT',
url: `${url}/remoteConfig?validate_only=true`,
headers: { ...FIREBASE_REMOTE_CONFIG_HEADERS, 'If-Match': template.etag },
data: {
conditions: template.conditions,
parameters: template.parameters,
}
};
return this.httpClient.send(request);
})
.then((resp) => {
if (!validator.isNonEmptyString(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,
// validating a template returns an etag with the suffix -0 means that your update
// was successfully validated. We set the etag back to the original etag of the template
// to allow future operations.
etag: template.etag,
};
})
.catch((err) => {
throw this.toFirebaseError(err);
});
}

private getUrl(): Promise<string> {
return this.getProjectIdPrefix()
.then((projectIdPrefix) => {
Expand Down Expand Up @@ -185,9 +220,14 @@ interface Error {
}

const ERROR_CODE_MAPPING: { [key: string]: RemoteConfigErrorCode } = {
ABORTED: 'aborted',
ALREADY_EXISTS: `already-exists`,
INVALID_ARGUMENT: 'invalid-argument',
FAILED_PRECONDITION: 'failed-precondition',
NOT_FOUND: 'not-found',
OUT_OF_RANGE: 'out-of-range',
PERMISSION_DENIED: 'permission-denied',
RESOURCE_EXHAUSTED: 'resource-exhausted',
UNAUTHENTICATED: 'authentication-error',
UNAUTHENTICATED: 'unauthenticated',
UNKNOWN: 'unknown-error',
};
9 changes: 4 additions & 5 deletions src/remote-config/remote-config-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@
import { PrefixedFirebaseError } from '../utils/error';

export type RemoteConfigErrorCode =
'authentication-error'
| 'duplicate-key'
'aborted'
| 'already-exists'
| 'failed-precondition'
| 'internal-error'
| 'invalid-argument'
| 'invalid-etag'
| 'invalid-template'
| 'not-found'
| 'obsolete-etag'
| 'out-of-range'
| 'permission-denied'
| 'resource-exhausted'
| 'unauthenticated'
Expand Down
60 changes: 22 additions & 38 deletions src/remote-config/remote-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,11 @@ import * as validator from '../utils/validator';
import { FirebaseRemoteConfigError } from './remote-config-utils';
import {
RemoteConfigApiClient,
RemoteConfigResponse,
RemoteConfigParameter
RemoteConfigTemplate,
RemoteConfigParameter,
RemoteConfigCondition,
} from './remote-config-api-client';

/** Interface representing a Remote Config condition. */
export interface RemoteConfigCondition {
name: string;
expression: string;
color?: string;
}

/**
* Internals of an RemoteConfig service instance.
*/
Expand Down Expand Up @@ -70,21 +64,35 @@ export class RemoteConfig implements FirebaseServiceInterface {
public getTemplate(): Promise<RemoteConfigTemplate> {
return this.client.getTemplate()
.then((templateResponse) => {
return new RemoteConfigTemplate(templateResponse);
return new RemoteConfigTemplateImpl(templateResponse);
});
}

/**
* Validates a Remote Config template.
*
* @param {RemoteConfigTemplate} template The Remote Config template to be validated.
*
* @return {Promise<RemoteConfigTemplate>} A Promise that fulfills when a template is validated.
*/
public validateTemplate(template: RemoteConfigTemplate): Promise<RemoteConfigTemplate> {
return this.client.validateTemplate(template)
.then((templateResponse) => {
return new RemoteConfigTemplateImpl(templateResponse);
});
}
}

/**
* Remote Config template class.
* Remote Config template internal implementation.
*/
export class RemoteConfigTemplate {
class RemoteConfigTemplateImpl implements RemoteConfigTemplate {

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

constructor(config: RemoteConfigResponse) {
constructor(config: RemoteConfigTemplate) {
if (!validator.isNonNullObject(config) ||
!validator.isNonEmptyString(config.etag)) {
throw new FirebaseRemoteConfigError(
Expand All @@ -111,11 +119,7 @@ export class RemoteConfigTemplate {
'invalid-argument',
`Remote Config conditions must be an array`);
}
this.conditions = config.conditions.map(p => ({
name: p.name,
expression: p.expression,
color: p.tagColor
}));
this.conditions = config.conditions;
} else {
this.conditions = [];
}
Expand All @@ -129,24 +133,4 @@ export class RemoteConfigTemplate {
get etag(): string {
return this.etagInternal;
}

/**
* Find an existing Remote Config condition by name.
*
* @param {string} name The name of the Remote Config condition.
*
* @return {RemoteConfigCondition} The Remote Config condition with the provided name.
*/
public getCondition(name: string): RemoteConfigCondition | undefined {
return this.conditions.find((c) => c.name === name);
}

/** @return {object} The plain object representation of the current template data. */
public toJSON(): object {
return {
parameters: this.parameters,
conditions: this.conditions,
etag: this.etag,
};
}
}
125 changes: 123 additions & 2 deletions test/unit/remote-config/remote-config-api-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
import * as _ from 'lodash';
import * as chai from 'chai';
import * as sinon from 'sinon';
import { RemoteConfigApiClient } from '../../../src/remote-config/remote-config-api-client';
import {
RemoteConfigApiClient,
RemoteConfigTemplate,
RemoteConfigConditionDisplayColor
} from '../../../src/remote-config/remote-config-api-client';
import { FirebaseRemoteConfigError } from '../../../src/remote-config/remote-config-utils';
import { HttpClient } from '../../../src/utils/api-request';
import * as utils from '../utils';
Expand Down Expand Up @@ -55,6 +59,23 @@ describe('RemoteConfigApiClient', () => {
const clientWithoutProjectId = new RemoteConfigApiClient(
mocks.mockCredentialApp());

const REMOTE_CONFIG_TEMPLATE: RemoteConfigTemplate = {
conditions: [{
name: 'ios',
expression: 'device.os == \'ios\'',
tagColor: RemoteConfigConditionDisplayColor.PINK,
}],
parameters: {
// eslint-disable-next-line @typescript-eslint/camelcase
holiday_promo_enabled: {
defaultValue: { value: 'true' },
conditionalValues: { ios: { useInAppDefault: true } },
description: 'this is a promo',
},
},
etag: 'etag-123456789012-6',
};

// Stubs used to simulate underlying api calls.
let stubs: sinon.SinonStub[] = [];
let app: FirebaseApp;
Expand Down Expand Up @@ -128,7 +149,7 @@ describe('RemoteConfigApiClient', () => {
.should.eventually.be.rejected.and.deep.equal(expected);
});

it('should reject unknown-error when error code is not present', () => {
it('should reject with unknown-error when error code is not present', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom({}, 404));
Expand Down Expand Up @@ -159,4 +180,104 @@ describe('RemoteConfigApiClient', () => {
.should.eventually.be.rejected.and.deep.equal(expected);
});
});

describe('validateTemplate', () => {
const testResponse = {
conditions: [{ name: 'ios', expression: 'exp' }],
parameters: { param: { defaultValue: { value: 'true' } } },
version: {},
};

it(`should reject when project id is not available`, () => {
return clientWithoutProjectId.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejectedWith(noProjectId);
});

it('should resolve with the requested template on success', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(testResponse, 200, { etag: 'etag-123456789012-0' }));
stubs.push(stub);
return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.then((resp) => {
expect(resp.conditions).to.deep.equal(testResponse.conditions);
expect(resp.parameters).to.deep.equal(testResponse.parameters);
// validate template returns an etag with the suffix -0 when successful.
// verify that the etag matches the original template etag.
expect(resp.etag).to.equal('etag-123456789012-6');
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'PUT',
url: 'https://firebaseremoteconfig.googleapis.com/v1/projects/test-project/remoteConfig?validate_only=true',
headers: { ...EXPECTED_HEADERS, 'If-Match': REMOTE_CONFIG_TEMPLATE.etag },
data: {
conditions: REMOTE_CONFIG_TEMPLATE.conditions,
parameters: REMOTE_CONFIG_TEMPLATE.parameters,
}
});
});
});

it('should reject when the etag is not present', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(testResponse));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.');
return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});

it('should reject when a full platform error response is received', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom(ERROR_RESPONSE, 404));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError('not-found', 'Requested entity not found');
return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});

it('should reject with unknown-error when error code is not present', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom({}, 404));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError('unknown-error', 'Unknown server error: {}');
return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});

it('should reject with unknown-error for non-json response', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom('not json', 404));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError(
'unknown-error', 'Unexpected response with status: 404 and body: not json');
return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});

const errorMessages = [
"[VALIDATION_ERROR]: [foo] are not valid condition names. All keys in all conditional value maps must be valid condition names.",
"[VERSION_MISMATCH]: Expected version 6, found 8 for project: 123456789012"
];
errorMessages.forEach((message) => {
it('should reject with failed-precondition when a validation error occurres', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.rejects(utils.errorFrom({
error: {
code: 400,
message: message,
status: "FAILED_PRECONDITION"
}
}, 400));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError('failed-precondition', message);
return apiClient.validateTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});
});
});
});
Loading