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
66 changes: 52 additions & 14 deletions src/remote-config/remote-config-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request';
import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse } from '../utils/api-request';
import { PrefixedFirebaseError } from '../utils/error';
import { FirebaseRemoteConfigError, RemoteConfigErrorCode } from './remote-config-utils';
import { FirebaseApp } from '../firebase-app';
Expand Down Expand Up @@ -127,19 +127,7 @@ 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);
})
return this.sendPutRequest(template, template.etag, true)
.then((resp) => {
if (!validator.isNonEmptyString(resp.headers['etag'])) {
throw new FirebaseRemoteConfigError(
Expand All @@ -160,6 +148,56 @@ export class RemoteConfigApiClient {
});
}

public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise<RemoteConfigTemplate> {
let ifMatch: string = template.etag;
if (options && options.force == true) {
// setting `If-Match: *` forces the Remote Config template to be updated
// and circumvent the ETag, and the protection from that it provides.
ifMatch = '*';
}
return this.sendPutRequest(template, ifMatch)
.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,
etag: resp.headers['etag'],
};
})
.catch((err) => {
throw this.toFirebaseError(err);
});
}

private sendPutRequest(template: RemoteConfigTemplate, etag: string, validateOnly?: boolean): Promise<HttpResponse> {
if (!validator.isNonEmptyString(etag)) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
'ETag must be a non-empty string.');
}
let path = 'remoteConfig';
if (validateOnly) {
path += '?validate_only=true';
}
return this.getUrl()
.then((url) => {
const request: HttpRequestConfig = {
method: 'PUT',
url: `${url}/${path}`,
headers: { ...FIREBASE_REMOTE_CONFIG_HEADERS, 'If-Match': etag },
data: {
conditions: template.conditions,
parameters: template.parameters,
}
};
return this.httpClient.send(request);
});
}

private getUrl(): Promise<string> {
return this.getProjectIdPrefix()
.then((projectIdPrefix) => {
Expand Down
15 changes: 15 additions & 0 deletions src/remote-config/remote-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ export class RemoteConfig implements FirebaseServiceInterface {
return new RemoteConfigTemplateImpl(templateResponse);
});
}

/**
* Publishes a Remote Config template.
*
* @param {RemoteConfigTemplate} template The Remote Config template to be validated.
* @param {any=} options Optional options object when publishing a Remote Config template.
*
* @return {Promise<RemoteConfigTemplate>} A Promise that fulfills when a template is published.
*/
public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise<RemoteConfigTemplate> {
return this.client.publishTemplate(template, options)
.then((templateResponse) => {
return new RemoteConfigTemplateImpl(templateResponse);
});
}
}

/**
Expand Down
155 changes: 130 additions & 25 deletions test/unit/remote-config/remote-config-api-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,24 @@ describe('RemoteConfigApiClient', () => {
status: 'NOT_FOUND',
},
};

const VALIDATION_ERROR_MESSAGES = [
"[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"
];

const EXPECTED_HEADERS = {
'Authorization': 'Bearer mock-token',
'X-Firebase-Client': 'fire-admin-node/<XXX_SDK_VERSION_XXX>',
'Accept-Encoding': 'gzip',
};

const TEST_RESPONSE = {
conditions: [{ name: 'ios', expression: 'exp' }],
parameters: { param: { defaultValue: { value: 'true' } } },
version: {},
};

const noProjectId = '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.';
Expand Down Expand Up @@ -100,12 +113,6 @@ describe('RemoteConfigApiClient', () => {
});

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

it(`should reject when project id is not available`, () => {
return clientWithoutProjectId.getTemplate()
.should.eventually.be.rejectedWith(noProjectId);
Expand All @@ -114,12 +121,12 @@ describe('RemoteConfigApiClient', () => {
it('should resolve with the requested template on success', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(testResponse, 200, { etag: 'etag-123456789012-1' }));
.resolves(utils.responseFrom(TEST_RESPONSE, 200, { etag: 'etag-123456789012-1' }));
stubs.push(stub);
return apiClient.getTemplate()
.then((resp) => {
expect(resp.conditions).to.deep.equal(testResponse.conditions);
expect(resp.parameters).to.deep.equal(testResponse.parameters);
expect(resp.conditions).to.deep.equal(TEST_RESPONSE.conditions);
expect(resp.parameters).to.deep.equal(TEST_RESPONSE.parameters);
expect(resp.etag).to.equal('etag-123456789012-1');
expect(stub).to.have.been.calledOnce.and.calledWith({
method: 'GET',
Expand All @@ -132,7 +139,7 @@ describe('RemoteConfigApiClient', () => {
it('should reject when the etag is not present', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(testResponse));
.resolves(utils.responseFrom(TEST_RESPONSE));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.');
return apiClient.getTemplate()
Expand Down Expand Up @@ -182,12 +189,6 @@ describe('RemoteConfigApiClient', () => {
});

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);
Expand All @@ -196,12 +197,12 @@ describe('RemoteConfigApiClient', () => {
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' }));
.resolves(utils.responseFrom(TEST_RESPONSE, 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);
expect(resp.conditions).to.deep.equal(TEST_RESPONSE.conditions);
expect(resp.parameters).to.deep.equal(TEST_RESPONSE.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');
Expand All @@ -220,13 +221,20 @@ describe('RemoteConfigApiClient', () => {
it('should reject when the etag is not present', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(testResponse));
.resolves(utils.responseFrom(TEST_RESPONSE));
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);
});

[null, undefined, ''].forEach((etag) => {
it('should reject when the etag in template is null, undefined, or an empty string', () => {
expect(() => apiClient.validateTemplate({ conditions: [], parameters: {}, etag: etag as any }))
.to.throw('ETag must be a non-empty string.');
});
});

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New test case for null, undefined, and empty etags.

it('should reject when a full platform error response is received', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
Expand Down Expand Up @@ -258,11 +266,7 @@ describe('RemoteConfigApiClient', () => {
.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) => {
VALIDATION_ERROR_MESSAGES.forEach((message) => {
it('should reject with failed-precondition when a validation error occurres', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
Expand All @@ -280,4 +284,105 @@ describe('RemoteConfigApiClient', () => {
});
});
});

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

const testOptions = [
{ options: undefined, etag: 'etag-123456789012-6' },
{ options: { force: true }, etag: '*' }
];
testOptions.forEach((option) => {
it('should resolve with the requested template on success', () => {
const stub = sinon
.stub(HttpClient.prototype, 'send')
.resolves(utils.responseFrom(TEST_RESPONSE, 200, { etag: 'etag-123456789012-6' }));
stubs.push(stub);
return apiClient.publishTemplate(REMOTE_CONFIG_TEMPLATE, option.options)
.then((resp) => {
expect(resp.conditions).to.deep.equal(TEST_RESPONSE.conditions);
expect(resp.parameters).to.deep.equal(TEST_RESPONSE.parameters);
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',
headers: { ...EXPECTED_HEADERS, 'If-Match': option.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(TEST_RESPONSE));
stubs.push(stub);
const expected = new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.');
return apiClient.publishTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});

[null, undefined, ''].forEach((etag) => {
it('should reject when the etag in template is null, undefined, or an empty string', () => {
expect(() => apiClient.publishTemplate({ conditions: [], parameters: {}, etag: etag as any }))
.to.throw('ETag must be a non-empty string.');
});
});

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.publishTemplate(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.publishTemplate(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.publishTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});

VALIDATION_ERROR_MESSAGES.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.publishTemplate(REMOTE_CONFIG_TEMPLATE)
.should.eventually.be.rejected.and.deep.equal(expected);
});
});
});
});
Loading