diff --git a/gulpfile.js b/gulpfile.js index a3ef4a5b86..b4f216a638 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -57,6 +57,7 @@ var paths = { '!src/instance-id.d.ts', '!src/security-rules.d.ts', '!src/project-management.d.ts', + '!src/remote-config.d.ts', '!src/messaging.d.ts', ], }; @@ -70,7 +71,6 @@ const TEMPORARY_TYPING_EXCLUDES = [ '!lib/database/*.d.ts', '!lib/firestore/*.d.ts', '!lib/machine-learning/*.d.ts', - '!lib/remote-config/*.d.ts', '!lib/storage/*.d.ts', '!lib/utils/*.d.ts', ]; diff --git a/src/remote-config/index.ts b/src/remote-config/index.ts new file mode 100644 index 0000000000..d9450b83b1 --- /dev/null +++ b/src/remote-config/index.ts @@ -0,0 +1,57 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '../firebase-app'; +import * as remoteConfigApi from './remote-config'; +import * as remoteConfigClientApi from './remote-config-api-client'; +import * as firebaseAdmin from '../index'; + +export function remoteConfig(app?: FirebaseApp): remoteConfigApi.RemoteConfig { + if (typeof(app) === 'undefined') { + app = firebaseAdmin.app(); + } + return app.remoteConfig(); +} + +/** + * We must define a namespace to make the typings work correctly. Otherwise + * `admin.remoteConfig()` cannot be called like a function. Temporarily, + * admin.remoteConfig is used as the namespace name because we cannot barrel + * re-export the contents from remote-config, and we want it to + * match the namespacing in the re-export inside src/index.d.ts + */ +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace admin.remoteConfig { + // See https://github.com/microsoft/TypeScript/issues/4336 + /* eslint-disable @typescript-eslint/no-unused-vars */ + // See https://github.com/typescript-eslint/typescript-eslint/issues/363 + export import ExplicitParameterValue = remoteConfigClientApi.ExplicitParameterValue; + export import ListVersionsOptions = remoteConfigClientApi.ListVersionsOptions; + export import ListVersionsResult = remoteConfigClientApi.ListVersionsResult; + export import InAppDefaultValue = remoteConfigClientApi.InAppDefaultValue; + export import RemoteConfigCondition = remoteConfigClientApi.RemoteConfigCondition; + export import RemoteConfigParameter = remoteConfigClientApi.RemoteConfigParameter; + export import RemoteConfigParameterGroup = remoteConfigClientApi.RemoteConfigParameterGroup; + export import RemoteConfigParameterValue = remoteConfigClientApi.RemoteConfigParameterValue; + export import RemoteConfigTemplate = remoteConfigClientApi.RemoteConfigTemplate; + export import RemoteConfigUser = remoteConfigClientApi.RemoteConfigUser; + export import TagColor = remoteConfigClientApi.TagColor; + export import Version = remoteConfigClientApi.Version; + + // Allows for exposing classes as interfaces in typings + /* eslint-disable @typescript-eslint/no-empty-interface */ + export interface RemoteConfig extends remoteConfigApi.RemoteConfig {} +} diff --git a/src/remote-config/remote-config-api-client-internal.ts b/src/remote-config/remote-config-api-client-internal.ts new file mode 100644 index 0000000000..315c9582b4 --- /dev/null +++ b/src/remote-config/remote-config-api-client-internal.ts @@ -0,0 +1,445 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RemoteConfigTemplate, ListVersionsOptions, ListVersionsResult } from './remote-config-api-client'; +import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse } from '../utils/api-request'; +import { PrefixedFirebaseError } from '../utils/error'; +import { FirebaseApp } from '../firebase-app'; +import * as utils from '../utils/index'; +import * as validator from '../utils/validator'; +import { deepCopy } from '../utils/deep-copy'; + +// 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/${utils.getSdkVersion()}`, + // 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', +}; + + +/** + * Class that facilitates sending requests to the Firebase Remote Config backend API. + * + * @private + */ +export class RemoteConfigApiClient { + 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.'); + } + + this.httpClient = new AuthorizedHttpClient(app); + } + + public getTemplate(): Promise { + 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) => { + return this.toRemoteConfigTemplate(resp); + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + public getTemplateAtVersion(versionNumber: number | string): Promise { + const data = { versionNumber: this.validateVersionNumber(versionNumber) }; + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'GET', + url: `${url}/remoteConfig`, + headers: FIREBASE_REMOTE_CONFIG_HEADERS, + data + }; + return this.httpClient.send(request); + }) + .then((resp) => { + return this.toRemoteConfigTemplate(resp); + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + public validateTemplate(template: RemoteConfigTemplate): Promise { + template = this.validateInputRemoteConfigTemplate(template); + return this.sendPutRequest(template, template.etag, true) + .then((resp) => { + // 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. + this.validateEtag(resp.headers['etag']); + return this.toRemoteConfigTemplate(resp, template.etag); + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise { + template = this.validateInputRemoteConfigTemplate(template); + 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) => { + return this.toRemoteConfigTemplate(resp); + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + public rollback(versionNumber: number | string): Promise { + const data = { versionNumber: this.validateVersionNumber(versionNumber) }; + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'POST', + url: `${url}/remoteConfig:rollback`, + headers: FIREBASE_REMOTE_CONFIG_HEADERS, + data + }; + return this.httpClient.send(request); + }) + .then((resp) => { + return this.toRemoteConfigTemplate(resp); + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + public listVersions(options?: ListVersionsOptions): Promise { + if (typeof options !== 'undefined') { + options = this.validateListVersionsOptions(options); + } + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'GET', + url: `${url}/remoteConfig:listVersions`, + headers: FIREBASE_REMOTE_CONFIG_HEADERS, + data: options + }; + return this.httpClient.send(request); + }) + .then((resp) => { + return resp.data; + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + private sendPutRequest(template: RemoteConfigTemplate, etag: string, validateOnly?: boolean): Promise { + 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, + parameterGroups: template.parameterGroups, + version: template.version, + } + }; + return this.httpClient.send(request); + }); + } + + private getUrl(): Promise { + return this.getProjectIdPrefix() + .then((projectIdPrefix) => { + return `${FIREBASE_REMOTE_CONFIG_V1_API}/${projectIdPrefix}`; + }); + } + + private getProjectIdPrefix(): Promise { + 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); + } + + /** + * Creates a RemoteConfigTemplate from the API response. + * If provided, customEtag is used instead of the etag returned in the API response. + * + * @param {HttpResponse} resp API response object. + * @param {string} customEtag A custom etag to replace the etag fom the API response (Optional). + */ + private toRemoteConfigTemplate(resp: HttpResponse, customEtag?: string): RemoteConfigTemplate { + const etag = (typeof customEtag == 'undefined') ? resp.headers['etag'] : customEtag; + this.validateEtag(etag); + return { + conditions: resp.data.conditions, + parameters: resp.data.parameters, + parameterGroups: resp.data.parameterGroups, + etag, + version: resp.data.version, + }; + } + + /** + * Checks if the given RemoteConfigTemplate object is valid. + * The object must have valid parameters, parameter groups, conditions, and an etag. + * Removes output only properties from version metadata. + * + * @param {RemoteConfigTemplate} template A RemoteConfigTemplate object to be validated. + * + * @returns {RemoteConfigTemplate} The validated RemoteConfigTemplate object. + */ + private validateInputRemoteConfigTemplate(template: RemoteConfigTemplate): RemoteConfigTemplate { + const templateCopy = deepCopy(template); + if (!validator.isNonNullObject(templateCopy)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `Invalid Remote Config template: ${JSON.stringify(templateCopy)}`); + } + if (!validator.isNonEmptyString(templateCopy.etag)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'ETag must be a non-empty string.'); + } + if (!validator.isNonNullObject(templateCopy.parameters)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'Remote Config parameters must be a non-null object'); + } + if (!validator.isNonNullObject(templateCopy.parameterGroups)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'Remote Config parameter groups must be a non-null object'); + } + if (!validator.isArray(templateCopy.conditions)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'Remote Config conditions must be an array'); + } + if (typeof templateCopy.version !== 'undefined') { + // exclude output only properties and keep the only input property: description + templateCopy.version = { description: templateCopy.version.description }; + } + return templateCopy; + } + + /** + * Checks if a given version number is valid. + * A version number must be an integer or a string in int64 format. + * If valid, returns the string representation of the provided version number. + * + * @param {string|number} versionNumber A version number to be validated. + * + * @returns {string} The validated version number as a string. + */ + private validateVersionNumber(versionNumber: string | number, propertyName = 'versionNumber'): string { + if (!validator.isNonEmptyString(versionNumber) && + !validator.isNumber(versionNumber)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `${propertyName} must be a non-empty string in int64 format or a number`); + } + if (!Number.isInteger(Number(versionNumber))) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + `${propertyName} must be an integer or a string in int64 format`); + } + return versionNumber.toString(); + } + + private validateEtag(etag?: string): void { + if (!validator.isNonEmptyString(etag)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'ETag header is not present in the server response.'); + } + } + + /** + * Checks if a given `ListVersionsOptions` object is valid. If successful, creates a copy of the + * options object and convert `startTime` and `endTime` to RFC3339 UTC "Zulu" format, if present. + * + * @param {ListVersionsOptions} options An options object to be validated. + * + * @return {ListVersionsOptions} A copy of the provided options object with timestamps converted + * to UTC Zulu format. + */ + private validateListVersionsOptions(options: ListVersionsOptions): ListVersionsOptions { + const optionsCopy = deepCopy(options); + if (!validator.isNonNullObject(optionsCopy)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', + 'ListVersionsOptions must be a non-null object.'); + } + if (typeof optionsCopy.pageSize !== 'undefined') { + if (!validator.isNumber(optionsCopy.pageSize)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', 'pageSize must be a number.'); + } + if (optionsCopy.pageSize < 1 || optionsCopy.pageSize > 300) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', 'pageSize must be a number between 1 and 300 (inclusive).'); + } + } + if (typeof optionsCopy.pageToken !== 'undefined' && !validator.isNonEmptyString(optionsCopy.pageToken)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', 'pageToken must be a string value.'); + } + if (typeof optionsCopy.endVersionNumber !== 'undefined') { + optionsCopy.endVersionNumber = this.validateVersionNumber(optionsCopy.endVersionNumber, 'endVersionNumber'); + } + if (typeof optionsCopy.startTime !== 'undefined') { + if (!(optionsCopy.startTime instanceof Date) && !validator.isUTCDateString(optionsCopy.startTime)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', 'startTime must be a valid Date object or a UTC date string.'); + } + // Convert startTime to RFC3339 UTC "Zulu" format. + if (optionsCopy.startTime instanceof Date) { + optionsCopy.startTime = optionsCopy.startTime.toISOString(); + } else { + optionsCopy.startTime = new Date(optionsCopy.startTime).toISOString(); + } + } + if (typeof optionsCopy.endTime !== 'undefined') { + if (!(optionsCopy.endTime instanceof Date) && !validator.isUTCDateString(optionsCopy.endTime)) { + throw new FirebaseRemoteConfigError( + 'invalid-argument', 'endTime must be a valid Date object or a UTC date string.'); + } + // Convert endTime to RFC3339 UTC "Zulu" format. + if (optionsCopy.endTime instanceof Date) { + optionsCopy.endTime = optionsCopy.endTime.toISOString(); + } else { + optionsCopy.endTime = new Date(optionsCopy.endTime).toISOString(); + } + } + // Remove undefined fields from optionsCopy + Object.keys(optionsCopy).forEach(key => + (typeof (optionsCopy as any)[key] === 'undefined') && delete (optionsCopy as any)[key] + ); + return optionsCopy; + } +} + +interface ErrorResponse { + error?: Error; +} + +interface Error { + code?: number; + message?: string; + status?: string; +} + +const ERROR_CODE_MAPPING: { [key: string]: RemoteConfigErrorCode } = { + ABORTED: 'aborted', + ALREADY_EXISTS: `already-exists`, + INVALID_ARGUMENT: 'invalid-argument', + INTERNAL: 'internal-error', + FAILED_PRECONDITION: 'failed-precondition', + NOT_FOUND: 'not-found', + OUT_OF_RANGE: 'out-of-range', + PERMISSION_DENIED: 'permission-denied', + RESOURCE_EXHAUSTED: 'resource-exhausted', + UNAUTHENTICATED: 'unauthenticated', + UNKNOWN: 'unknown-error', +}; + +export type RemoteConfigErrorCode = + 'aborted' + | 'already-exists' + | 'failed-precondition' + | 'internal-error' + | 'invalid-argument' + | 'not-found' + | 'out-of-range' + | 'permission-denied' + | 'resource-exhausted' + | 'unauthenticated' + | 'unknown-error'; + +/** + * Firebase Remote Config error code structure. This extends PrefixedFirebaseError. + * + * @param {RemoteConfigErrorCode} code The error code. + * @param {string} message The error message. + * @constructor + */ +export class FirebaseRemoteConfigError extends PrefixedFirebaseError { + constructor(code: RemoteConfigErrorCode, message: string) { + super('remote-config', code, message); + } +} diff --git a/src/remote-config/remote-config-api-client.ts b/src/remote-config/remote-config-api-client.ts index 26bd1b8931..08621f972b 100644 --- a/src/remote-config/remote-config-api-client.ts +++ b/src/remote-config/remote-config-api-client.ts @@ -14,25 +14,9 @@ * limitations under the License. */ -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'; -import * as utils from '../utils/index'; -import * as validator from '../utils/validator'; -import { deepCopy } from '../utils/deep-copy'; - -// 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/${utils.getSdkVersion()}`, - // 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', -}; - +/** + * Colors that are associated with conditions for display purposes. + */ export enum TagColor { BLUE = "Blue", BROWN = "Brown", @@ -47,464 +31,254 @@ export enum TagColor { TEAL = "Teal", } -/** Interface representing a Remote Config parameter `value` in value options. */ +/** + * Interface representing an explicit parameter value. + */ export interface ExplicitParameterValue { + /** + * The `string` value that the parameter is set to. + */ value: string; } -/** Interface representing a Remote Config parameter `useInAppDefault` in value options. */ +/** + * Interface representing an in-app-default value. + */ export interface InAppDefaultValue { + /** + * If `true`, the parameter is omitted from the parameter values returned to a client. + */ useInAppDefault: boolean; } +/** + * Type representing a Remote Config parameter value. + * A `RemoteConfigParameterValue` could be either an `ExplicitParameterValue` or + * an `InAppDefaultValue`. + */ export type RemoteConfigParameterValue = ExplicitParameterValue | InAppDefaultValue; -/** Interface representing a Remote Config parameter. */ +/** + * Interface representing a Remote Config parameter. + * At minimum, a `defaultValue` or a `conditionalValues` entry must be present for the + * parameter to have any effect. + */ export interface RemoteConfigParameter { + + /** + * The value to set the parameter to, when none of the named conditions evaluate to `true`. + */ defaultValue?: RemoteConfigParameterValue; + + /** + * A `(condition name, value)` map. The condition name of the highest priority + * (the one listed first in the Remote Config template's conditions list) determines the value of + * this parameter. + */ conditionalValues?: { [key: string]: RemoteConfigParameterValue }; + + /** + * A description for this parameter. Should not be over 100 characters and may contain any + * Unicode characters. + */ description?: string; } -/** Interface representing a Remote Config parameter group. */ +/** + * Interface representing a Remote Config parameter group. + * Grouping parameters is only for management purposes and does not affect client-side + * fetching of parameter values. + */ export interface RemoteConfigParameterGroup { + /** + * A description for the group. Its length must be less than or equal to 256 characters. + * A description may contain any Unicode characters. + */ description?: string; + + /** + * Map of parameter keys to their optional default values and optional conditional values for + * parameters that belong to this group. A parameter only appears once per + * Remote Config template. An ungrouped parameter appears at the top level, whereas a + * parameter organized within a group appears within its group's map of parameters. + */ parameters: { [key: string]: RemoteConfigParameter }; } -/** Interface representing a Remote Config condition. */ +/** + * Interface representing a Remote Config condition. + * A condition targets a specific group of users. A list of these conditions make up + * part of a Remote Config template. + */ export interface RemoteConfigCondition { + + /** + * A non-empty and unique name of this condition. + */ name: string; + + /** + * The logic of this condition. + * See the documentation on + * {@link https://firebase.google.com/docs/remote-config/condition-reference condition expressions} + * for the expected syntax of this field. + */ expression: string; + + /** + * The color associated with this condition for display purposes in the Firebase Console. + * Not specifying this value results in the console picking an arbitrary color to associate + * with the condition. + */ tagColor?: TagColor; } -/** Interface representing a Remote Config template. */ +/** + * Interface representing a Remote Config template. + */ export interface RemoteConfigTemplate { + /** + * A list of conditions in descending order by priority. + */ conditions: RemoteConfigCondition[]; + + /** + * Map of parameter keys to their optional default values and optional conditional values. + */ parameters: { [key: string]: RemoteConfigParameter }; + + /** + * Map of parameter group names to their parameter group objects. + * A group's name is mutable but must be unique among groups in the Remote Config template. + * The name is limited to 256 characters and intended to be human-readable. Any Unicode + * characters are allowed. + */ parameterGroups: { [key: string]: RemoteConfigParameterGroup }; + + /** + * ETag of the current Remote Config template (readonly). + */ readonly etag: string; + + /** + * Version information for the current Remote Config template. + */ version?: Version; } -/** Interface representing a Remote Config version. */ +/** + * Interface representing a Remote Config template version. + * Output only, except for the version description. Contains metadata about a particular + * version of the Remote Config template. All fields are set at the time the specified Remote + * Config template is published. A version's description field may be specified in + * `publishTemplate` calls. + */ export interface Version { - versionNumber?: string; // int64 format - updateTime?: string; // in UTC + /** + * The version number of a Remote Config template. + */ + versionNumber?: string; + + /** + * The timestamp of when this version of the Remote Config template was written to the + * Remote Config backend. + */ + updateTime?: string; + + /** + * The origin of the template update action. + */ updateOrigin?: ('REMOTE_CONFIG_UPDATE_ORIGIN_UNSPECIFIED' | 'CONSOLE' | 'REST_API' | 'ADMIN_SDK_NODE'); + + /** + * The type of the template update action. + */ updateType?: ('REMOTE_CONFIG_UPDATE_TYPE_UNSPECIFIED' | 'INCREMENTAL_UPDATE' | 'FORCED_UPDATE' | 'ROLLBACK'); + + /** + * Aggregation of all metadata fields about the account that performed the update. + */ updateUser?: RemoteConfigUser; + + /** + * The user-provided description of the corresponding Remote Config template. + */ description?: string; + + /** + * The version number of the Remote Config template that has become the current version + * due to a rollback. Only present if this version is the result of a rollback. + */ rollbackSource?: string; + + /** + * Indicates whether this Remote Config template was published before version history was + * supported. + */ isLegacy?: boolean; } /** Interface representing a list of Remote Config template versions. */ export interface ListVersionsResult { + /** + * A list of version metadata objects, sorted in reverse chronological order. + */ versions: Version[]; + + /** + * Token to retrieve the next page of results, or empty if there are no more results + * in the list. + */ nextPageToken?: string; } -/** Interface representing a Remote Config list version options. */ +/** Interface representing options for Remote Config list versions operation. */ export interface ListVersionsOptions { + /** + * The maximum number of items to return per page. + */ pageSize?: number; + + /** + * The `nextPageToken` value returned from a previous list versions request, if any. + */ pageToken?: string; + + /** + * Specifies the newest version number to include in the results. + * If specified, must be greater than zero. Defaults to the newest version. + */ endVersionNumber?: string | number; + + /** + * Specifies the earliest update time to include in the results. Any entries updated before this + * time are omitted. + */ startTime?: Date | string; + + /** + * Specifies the latest update time to include in the results. Any entries updated on or after + * this time are omitted. + */ endTime?: Date | string; } -/** Interface representing a Remote Config user. */ +/** Interface representing a Remote Config user.*/ export interface RemoteConfigUser { + /** + * Email address. Output only. + */ email: string; - name?: string; - imageUrl?: string; -} - -/** - * Class that facilitates sending requests to the Firebase Remote Config backend API. - * - * @private - */ -export class RemoteConfigApiClient { - - 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.'); - } - - this.httpClient = new AuthorizedHttpClient(app); - } - - public getTemplate(): Promise { - 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) => { - return this.toRemoteConfigTemplate(resp); - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); - } - - public getTemplateAtVersion(versionNumber: number | string): Promise { - const data = { versionNumber: this.validateVersionNumber(versionNumber) }; - return this.getUrl() - .then((url) => { - const request: HttpRequestConfig = { - method: 'GET', - url: `${url}/remoteConfig`, - headers: FIREBASE_REMOTE_CONFIG_HEADERS, - data - }; - return this.httpClient.send(request); - }) - .then((resp) => { - return this.toRemoteConfigTemplate(resp); - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); - } - - public validateTemplate(template: RemoteConfigTemplate): Promise { - template = this.validateInputRemoteConfigTemplate(template); - return this.sendPutRequest(template, template.etag, true) - .then((resp) => { - // 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. - this.validateEtag(resp.headers['etag']); - return this.toRemoteConfigTemplate(resp, template.etag); - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); - } - - public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise { - template = this.validateInputRemoteConfigTemplate(template); - 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) => { - return this.toRemoteConfigTemplate(resp); - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); - } - - public rollback(versionNumber: number | string): Promise { - const data = { versionNumber: this.validateVersionNumber(versionNumber) }; - return this.getUrl() - .then((url) => { - const request: HttpRequestConfig = { - method: 'POST', - url: `${url}/remoteConfig:rollback`, - headers: FIREBASE_REMOTE_CONFIG_HEADERS, - data - }; - return this.httpClient.send(request); - }) - .then((resp) => { - return this.toRemoteConfigTemplate(resp); - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); - } - - public listVersions(options?: ListVersionsOptions): Promise { - if (typeof options !== 'undefined') { - options = this.validateListVersionsOptions(options); - } - return this.getUrl() - .then((url) => { - const request: HttpRequestConfig = { - method: 'GET', - url: `${url}/remoteConfig:listVersions`, - headers: FIREBASE_REMOTE_CONFIG_HEADERS, - data: options - }; - return this.httpClient.send(request); - }) - .then((resp) => { - return resp.data; - }) - .catch((err) => { - throw this.toFirebaseError(err); - }); - } - - private sendPutRequest(template: RemoteConfigTemplate, etag: string, validateOnly?: boolean): Promise { - 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, - parameterGroups: template.parameterGroups, - version: template.version, - } - }; - return this.httpClient.send(request); - }); - } - - private getUrl(): Promise { - return this.getProjectIdPrefix() - .then((projectIdPrefix) => { - return `${FIREBASE_REMOTE_CONFIG_V1_API}/${projectIdPrefix}`; - }); - } - - private getProjectIdPrefix(): Promise { - 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); - } - - /** - * Creates a RemoteConfigTemplate from the API response. - * If provided, customEtag is used instead of the etag returned in the API response. - * - * @param {HttpResponse} resp API response object. - * @param {string} customEtag A custom etag to replace the etag fom the API response (Optional). - */ - private toRemoteConfigTemplate(resp: HttpResponse, customEtag?: string): RemoteConfigTemplate { - const etag = (typeof customEtag == 'undefined') ? resp.headers['etag'] : customEtag; - this.validateEtag(etag); - return { - conditions: resp.data.conditions, - parameters: resp.data.parameters, - parameterGroups: resp.data.parameterGroups, - etag, - version: resp.data.version, - }; - } - - /** - * Checks if the given RemoteConfigTemplate object is valid. - * The object must have valid parameters, parameter groups, conditions, and an etag. - * Removes output only properties from version metadata. - * - * @param {RemoteConfigTemplate} template A RemoteConfigTemplate object to be validated. - * - * @returns {RemoteConfigTemplate} The validated RemoteConfigTemplate object. - */ - private validateInputRemoteConfigTemplate(template: RemoteConfigTemplate): RemoteConfigTemplate { - const templateCopy = deepCopy(template); - if (!validator.isNonNullObject(templateCopy)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - `Invalid Remote Config template: ${JSON.stringify(templateCopy)}`); - } - if (!validator.isNonEmptyString(templateCopy.etag)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - 'ETag must be a non-empty string.'); - } - if (!validator.isNonNullObject(templateCopy.parameters)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - 'Remote Config parameters must be a non-null object'); - } - if (!validator.isNonNullObject(templateCopy.parameterGroups)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - 'Remote Config parameter groups must be a non-null object'); - } - if (!validator.isArray(templateCopy.conditions)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - 'Remote Config conditions must be an array'); - } - if (typeof templateCopy.version !== 'undefined') { - // exclude output only properties and keep the only input property: description - templateCopy.version = { description: templateCopy.version.description }; - } - return templateCopy; - } - - /** - * Checks if a given version number is valid. - * A version number must be an integer or a string in int64 format. - * If valid, returns the string representation of the provided version number. - * - * @param {string|number} versionNumber A version number to be validated. - * - * @returns {string} The validated version number as a string. - */ - private validateVersionNumber(versionNumber: string | number, propertyName = 'versionNumber'): string { - if (!validator.isNonEmptyString(versionNumber) && - !validator.isNumber(versionNumber)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - `${propertyName} must be a non-empty string in int64 format or a number`); - } - if (!Number.isInteger(Number(versionNumber))) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - `${propertyName} must be an integer or a string in int64 format`); - } - return versionNumber.toString(); - } - - private validateEtag(etag?: string): void { - if (!validator.isNonEmptyString(etag)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - 'ETag header is not present in the server response.'); - } - } - - /** - * Checks if a given `ListVersionsOptions` object is valid. If successful, creates a copy of the - * options object and convert `startTime` and `endTime` to RFC3339 UTC "Zulu" format, if present. - * - * @param {ListVersionsOptions} options An options object to be validated. - * - * @return {ListVersionsOptions} A copy of the provided options object with timestamps converted - * to UTC Zulu format. - */ - private validateListVersionsOptions(options: ListVersionsOptions): ListVersionsOptions { - const optionsCopy = deepCopy(options); - if (!validator.isNonNullObject(optionsCopy)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', - 'ListVersionsOptions must be a non-null object.'); - } - if (typeof optionsCopy.pageSize !== 'undefined') { - if (!validator.isNumber(optionsCopy.pageSize)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', 'pageSize must be a number.'); - } - if (optionsCopy.pageSize < 1 || optionsCopy.pageSize > 300) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', 'pageSize must be a number between 1 and 300 (inclusive).'); - } - } - if (typeof optionsCopy.pageToken !== 'undefined' && !validator.isNonEmptyString(optionsCopy.pageToken)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', 'pageToken must be a string value.'); - } - if (typeof optionsCopy.endVersionNumber !== 'undefined') { - optionsCopy.endVersionNumber = this.validateVersionNumber(optionsCopy.endVersionNumber, 'endVersionNumber'); - } - if (typeof optionsCopy.startTime !== 'undefined') { - if (!(optionsCopy.startTime instanceof Date) && !validator.isUTCDateString(optionsCopy.startTime)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', 'startTime must be a valid Date object or a UTC date string.'); - } - // Convert startTime to RFC3339 UTC "Zulu" format. - if (optionsCopy.startTime instanceof Date) { - optionsCopy.startTime = optionsCopy.startTime.toISOString(); - } else { - optionsCopy.startTime = new Date(optionsCopy.startTime).toISOString(); - } - } - if (typeof optionsCopy.endTime !== 'undefined') { - if (!(optionsCopy.endTime instanceof Date) && !validator.isUTCDateString(optionsCopy.endTime)) { - throw new FirebaseRemoteConfigError( - 'invalid-argument', 'endTime must be a valid Date object or a UTC date string.'); - } - // Convert endTime to RFC3339 UTC "Zulu" format. - if (optionsCopy.endTime instanceof Date) { - optionsCopy.endTime = optionsCopy.endTime.toISOString(); - } else { - optionsCopy.endTime = new Date(optionsCopy.endTime).toISOString(); - } - } - // Remove undefined fields from optionsCopy - Object.keys(optionsCopy).forEach(key => - (typeof (optionsCopy as any)[key] === 'undefined') && delete (optionsCopy as any)[key] - ); - return optionsCopy; - } -} -interface ErrorResponse { - error?: Error; -} + /** + * Display name. Output only. + */ + name?: string; -interface Error { - code?: number; - message?: string; - status?: string; + /** + * Image URL. Output only. + */ + imageUrl?: string; } - -const ERROR_CODE_MAPPING: { [key: string]: RemoteConfigErrorCode } = { - ABORTED: 'aborted', - ALREADY_EXISTS: `already-exists`, - INVALID_ARGUMENT: 'invalid-argument', - INTERNAL: 'internal-error', - FAILED_PRECONDITION: 'failed-precondition', - NOT_FOUND: 'not-found', - OUT_OF_RANGE: 'out-of-range', - PERMISSION_DENIED: 'permission-denied', - RESOURCE_EXHAUSTED: 'resource-exhausted', - UNAUTHENTICATED: 'unauthenticated', - UNKNOWN: 'unknown-error', -}; diff --git a/src/remote-config/remote-config-utils.ts b/src/remote-config/remote-config-utils.ts deleted file mode 100644 index 523eeb10bd..0000000000 --- a/src/remote-config/remote-config-utils.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*! - * Copyright 2020 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PrefixedFirebaseError } from '../utils/error'; - -export type RemoteConfigErrorCode = - 'aborted' - | 'already-exists' - | 'failed-precondition' - | 'internal-error' - | 'invalid-argument' - | 'not-found' - | 'out-of-range' - | 'permission-denied' - | 'resource-exhausted' - | 'unauthenticated' - | 'unknown-error'; - -/** - * Firebase Remote Config error code structure. This extends PrefixedFirebaseError. - * - * @param {RemoteConfigErrorCode} code The error code. - * @param {string} message The error message. - * @constructor - */ -export class FirebaseRemoteConfigError extends PrefixedFirebaseError { - constructor(code: RemoteConfigErrorCode, message: string) { - super('remote-config', code, message); - } -} diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index eface572db..4c67189a9f 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -17,9 +17,7 @@ import { FirebaseServiceInterface, FirebaseServiceInternalsInterface } from '../firebase-service'; import { FirebaseApp } from '../firebase-app'; import * as validator from '../utils/validator'; -import { FirebaseRemoteConfigError } from './remote-config-utils'; import { - RemoteConfigApiClient, RemoteConfigTemplate, RemoteConfigParameter, RemoteConfigCondition, @@ -29,6 +27,7 @@ import { RemoteConfigUser, Version, } from './remote-config-api-client'; +import { FirebaseRemoteConfigError, RemoteConfigApiClient } from './remote-config-api-client-internal'; /** * Internals of an RemoteConfig service instance. @@ -62,10 +61,11 @@ export class RemoteConfig implements FirebaseServiceInterface { } /** - * Gets the current active version of the Remote Config template of the project. - * - * @return {Promise} A Promise that fulfills when the template is available. - */ + * Gets the current active version of the {@link admin.remoteConfig.RemoteConfigTemplate + * `RemoteConfigTemplate`} of the project. + * + * @return A promise that fulfills with a `RemoteConfigTemplate`. + */ public getTemplate(): Promise { return this.client.getTemplate() .then((templateResponse) => { @@ -74,12 +74,13 @@ export class RemoteConfig implements FirebaseServiceInterface { } /** - * Gets the requested version of the Remote Config template of the project. - * - * @param {number | string} versionNumber Version number of the Remote Config template to look up. - * - * @return {Promise} A Promise that fulfills when the template is available. - */ + * Gets the requested version of the {@link admin.remoteConfig.RemoteConfigTemplate + * `RemoteConfigTemplate`} of the project. + * + * @param versionNumber Version number of the Remote Config template to look up. + * + * @return A promise that fulfills with a `RemoteConfigTemplate`. + */ public getTemplateAtVersion(versionNumber: number | string): Promise { return this.client.getTemplateAtVersion(versionNumber) .then((templateResponse) => { @@ -88,11 +89,10 @@ export class RemoteConfig implements FirebaseServiceInterface { } /** - * Validates a Remote Config template. + * Validates a {@link admin.remoteConfig.RemoteConfigTemplate `RemoteConfigTemplate`}. * - * @param {RemoteConfigTemplate} template The Remote Config template to be validated. - * - * @return {Promise} A Promise that fulfills when a template is validated. + * @param template The Remote Config template to be validated. + * @returns A promise that fulfills with the validated `RemoteConfigTemplate`. */ public validateTemplate(template: RemoteConfigTemplate): Promise { return this.client.validateTemplate(template) @@ -104,10 +104,16 @@ export class RemoteConfig implements FirebaseServiceInterface { /** * 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. + * @param template The Remote Config template to be published. + * @param options Optional options object when publishing a Remote Config template: + * - {boolean} `force` Setting this to `true` forces the Remote Config template to + * be updated and circumvent the ETag. This approach is not recommended + * because it risks causing the loss of updates to your Remote Config + * template if multiple clients are updating the Remote Config template. + * See {@link https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates + * ETag usage and forced updates}. * - * @return {Promise} A Promise that fulfills when a template is published. + * @return A Promise that fulfills with the published `RemoteConfigTemplate`. */ public publishTemplate(template: RemoteConfigTemplate, options?: { force: boolean }): Promise { return this.client.publishTemplate(template, options) @@ -117,13 +123,16 @@ export class RemoteConfig implements FirebaseServiceInterface { } /** - * Rollbacks a project's published Remote Config template to the specified version. + * Rolls back a project's published Remote Config template to the specified version. * A rollback is equivalent to getting a previously published Remote Config - * template, and re-publishing it using a force update. - * - * @param {number | string} versionNumber The version number of the Remote Config template - * to rollback to. - * @return {Promise} A Promise that fulfills with the published template. + * template and re-publishing it using a force update. + * + * @param versionNumber The version number of the Remote Config template to roll back to. + * The specified version number must be lower than the current version number, and not have + * been deleted due to staleness. Only the last 300 versions are stored. + * All versions that correspond to non-active Remote Config templates (that is, all except the + * template that is being fetched by clients) are also deleted if they are more than 90 days old. + * @return A promise that fulfills with the published `RemoteConfigTemplate`. */ public rollback(versionNumber: number | string): Promise { return this.client.rollback(versionNumber) @@ -133,14 +142,14 @@ export class RemoteConfig implements FirebaseServiceInterface { } /** - * Gets a list of Remote Config template versions that have been published, sorted in reverse - * chronological order. Only the last 300 versions are stored. - * All versions that correspond to non-active Remote Config templates (i.e., all except the - * template that is being fetched by clients) are also deleted if they are older than 90 days. - * - * @param {ListVersionsOptions} options Optional options object for getting a list of versions. - * @return A promise that fulfills with a `ListVersionsResult`. - */ + * Gets a list of Remote Config template versions that have been published, sorted in reverse + * chronological order. Only the last 300 versions are stored. + * All versions that correspond to non-active Remote Config templates (i.e., all except the + * template that is being fetched by clients) are also deleted if they are older than 90 days. + * + * @param {ListVersionsOptions} options Optional options object for getting a list of versions. + * @return A promise that fulfills with a `ListVersionsResult`. + */ public listVersions(options?: ListVersionsOptions): Promise { return this.client.listVersions(options) .then((listVersionsResponse) => { @@ -154,9 +163,9 @@ export class RemoteConfig implements FirebaseServiceInterface { /** * Creates and returns a new Remote Config template from a JSON string. * - * @param {string} json The JSON string to populate a Remote Config template. + * @param json The JSON string to populate a Remote Config template. * - * @return {RemoteConfigTemplate} A new template instance. + * @return A new template instance. */ public createTemplateFromJSON(json: string): RemoteConfigTemplate { if (!validator.isNonEmptyString(json)) { diff --git a/test/unit/remote-config/remote-config-api-client.spec.ts b/test/unit/remote-config/remote-config-api-client.spec.ts index 0561d0d40b..2b17171082 100644 --- a/test/unit/remote-config/remote-config-api-client.spec.ts +++ b/test/unit/remote-config/remote-config-api-client.spec.ts @@ -20,13 +20,12 @@ import * as _ from 'lodash'; import * as chai from 'chai'; import * as sinon from 'sinon'; import { - RemoteConfigApiClient, RemoteConfigTemplate, TagColor, ListVersionsResult, Version, } from '../../../src/remote-config/remote-config-api-client'; -import { FirebaseRemoteConfigError } from '../../../src/remote-config/remote-config-utils'; +import { FirebaseRemoteConfigError, RemoteConfigApiClient } from '../../../src/remote-config/remote-config-api-client-internal'; import { HttpClient } from '../../../src/utils/api-request'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; diff --git a/test/unit/remote-config/remote-config.spec.ts b/test/unit/remote-config/remote-config.spec.ts index 2490085bed..e7a3d08caa 100644 --- a/test/unit/remote-config/remote-config.spec.ts +++ b/test/unit/remote-config/remote-config.spec.ts @@ -23,13 +23,12 @@ import { RemoteConfig } from '../../../src/remote-config/remote-config'; import { FirebaseApp } from '../../../src/firebase-app'; import * as mocks from '../../resources/mocks'; import { - RemoteConfigApiClient, RemoteConfigTemplate, RemoteConfigCondition, TagColor, ListVersionsResult, } from '../../../src/remote-config/remote-config-api-client'; -import { FirebaseRemoteConfigError } from '../../../src/remote-config/remote-config-utils'; +import { FirebaseRemoteConfigError, RemoteConfigApiClient } from '../../../src/remote-config/remote-config-api-client-internal'; import { deepCopy } from '../../../src/utils/deep-copy'; const expect = chai.expect;