remote-config-api-client-internal.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. /*! firebase-admin v12.1.1 */
  2. "use strict";
  3. /*!
  4. * Copyright 2020 Google Inc.
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * You may obtain a copy of the License at
  9. *
  10. * http://www.apache.org/licenses/LICENSE-2.0
  11. *
  12. * Unless required by applicable law or agreed to in writing, software
  13. * distributed under the License is distributed on an "AS IS" BASIS,
  14. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  15. * See the License for the specific language governing permissions and
  16. * limitations under the License.
  17. */
  18. Object.defineProperty(exports, "__esModule", { value: true });
  19. exports.FirebaseRemoteConfigError = exports.RemoteConfigApiClient = void 0;
  20. const api_request_1 = require("../utils/api-request");
  21. const error_1 = require("../utils/error");
  22. const utils = require("../utils/index");
  23. const validator = require("../utils/validator");
  24. const deep_copy_1 = require("../utils/deep-copy");
  25. // Remote Config backend constants
  26. /**
  27. * Allows the `FIREBASE_REMOTE_CONFIG_URL_BASE` environment
  28. * variable to override the default API endpoint URL.
  29. */
  30. const FIREBASE_REMOTE_CONFIG_URL_BASE = process.env.FIREBASE_REMOTE_CONFIG_URL_BASE || 'https://firebaseremoteconfig.googleapis.com';
  31. const FIREBASE_REMOTE_CONFIG_HEADERS = {
  32. 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`,
  33. // There is a known issue in which the ETag is not properly returned in cases where the request
  34. // does not specify a compression type. Currently, it is required to include the header
  35. // `Accept-Encoding: gzip` or equivalent in all requests.
  36. // https://firebase.google.com/docs/remote-config/use-config-rest#etag_usage_and_forced_updates
  37. 'Accept-Encoding': 'gzip',
  38. };
  39. /**
  40. * Class that facilitates sending requests to the Firebase Remote Config backend API.
  41. *
  42. * @internal
  43. */
  44. class RemoteConfigApiClient {
  45. constructor(app) {
  46. this.app = app;
  47. if (!validator.isNonNullObject(app) || !('options' in app)) {
  48. throw new FirebaseRemoteConfigError('invalid-argument', 'First argument passed to admin.remoteConfig() must be a valid Firebase app instance.');
  49. }
  50. this.httpClient = new api_request_1.AuthorizedHttpClient(app);
  51. }
  52. getTemplate() {
  53. return this.getUrl()
  54. .then((url) => {
  55. const request = {
  56. method: 'GET',
  57. url: `${url}/remoteConfig`,
  58. headers: FIREBASE_REMOTE_CONFIG_HEADERS
  59. };
  60. return this.httpClient.send(request);
  61. })
  62. .then((resp) => {
  63. return this.toRemoteConfigTemplate(resp);
  64. })
  65. .catch((err) => {
  66. throw this.toFirebaseError(err);
  67. });
  68. }
  69. getTemplateAtVersion(versionNumber) {
  70. const data = { versionNumber: this.validateVersionNumber(versionNumber) };
  71. return this.getUrl()
  72. .then((url) => {
  73. const request = {
  74. method: 'GET',
  75. url: `${url}/remoteConfig`,
  76. headers: FIREBASE_REMOTE_CONFIG_HEADERS,
  77. data
  78. };
  79. return this.httpClient.send(request);
  80. })
  81. .then((resp) => {
  82. return this.toRemoteConfigTemplate(resp);
  83. })
  84. .catch((err) => {
  85. throw this.toFirebaseError(err);
  86. });
  87. }
  88. validateTemplate(template) {
  89. template = this.validateInputRemoteConfigTemplate(template);
  90. return this.sendPutRequest(template, template.etag, true)
  91. .then((resp) => {
  92. // validating a template returns an etag with the suffix -0 means that your update
  93. // was successfully validated. We set the etag back to the original etag of the template
  94. // to allow future operations.
  95. this.validateEtag(resp.headers['etag']);
  96. return this.toRemoteConfigTemplate(resp, template.etag);
  97. })
  98. .catch((err) => {
  99. throw this.toFirebaseError(err);
  100. });
  101. }
  102. publishTemplate(template, options) {
  103. template = this.validateInputRemoteConfigTemplate(template);
  104. let ifMatch = template.etag;
  105. if (options && options.force === true) {
  106. // setting `If-Match: *` forces the Remote Config template to be updated
  107. // and circumvent the ETag, and the protection from that it provides.
  108. ifMatch = '*';
  109. }
  110. return this.sendPutRequest(template, ifMatch)
  111. .then((resp) => {
  112. return this.toRemoteConfigTemplate(resp);
  113. })
  114. .catch((err) => {
  115. throw this.toFirebaseError(err);
  116. });
  117. }
  118. rollback(versionNumber) {
  119. const data = { versionNumber: this.validateVersionNumber(versionNumber) };
  120. return this.getUrl()
  121. .then((url) => {
  122. const request = {
  123. method: 'POST',
  124. url: `${url}/remoteConfig:rollback`,
  125. headers: FIREBASE_REMOTE_CONFIG_HEADERS,
  126. data
  127. };
  128. return this.httpClient.send(request);
  129. })
  130. .then((resp) => {
  131. return this.toRemoteConfigTemplate(resp);
  132. })
  133. .catch((err) => {
  134. throw this.toFirebaseError(err);
  135. });
  136. }
  137. listVersions(options) {
  138. if (typeof options !== 'undefined') {
  139. options = this.validateListVersionsOptions(options);
  140. }
  141. return this.getUrl()
  142. .then((url) => {
  143. const request = {
  144. method: 'GET',
  145. url: `${url}/remoteConfig:listVersions`,
  146. headers: FIREBASE_REMOTE_CONFIG_HEADERS,
  147. data: options
  148. };
  149. return this.httpClient.send(request);
  150. })
  151. .then((resp) => {
  152. return resp.data;
  153. })
  154. .catch((err) => {
  155. throw this.toFirebaseError(err);
  156. });
  157. }
  158. getServerTemplate() {
  159. return this.getUrl()
  160. .then((url) => {
  161. const request = {
  162. method: 'GET',
  163. url: `${url}/namespaces/firebase-server/serverRemoteConfig`,
  164. headers: FIREBASE_REMOTE_CONFIG_HEADERS
  165. };
  166. return this.httpClient.send(request);
  167. })
  168. .then((resp) => {
  169. return this.toRemoteConfigServerTemplate(resp);
  170. })
  171. .catch((err) => {
  172. throw this.toFirebaseError(err);
  173. });
  174. }
  175. sendPutRequest(template, etag, validateOnly) {
  176. let path = 'remoteConfig';
  177. if (validateOnly) {
  178. path += '?validate_only=true';
  179. }
  180. return this.getUrl()
  181. .then((url) => {
  182. const request = {
  183. method: 'PUT',
  184. url: `${url}/${path}`,
  185. headers: { ...FIREBASE_REMOTE_CONFIG_HEADERS, 'If-Match': etag },
  186. data: {
  187. conditions: template.conditions,
  188. parameters: template.parameters,
  189. parameterGroups: template.parameterGroups,
  190. version: template.version,
  191. }
  192. };
  193. return this.httpClient.send(request);
  194. });
  195. }
  196. getUrl() {
  197. return this.getProjectIdPrefix()
  198. .then((projectIdPrefix) => {
  199. return `${FIREBASE_REMOTE_CONFIG_URL_BASE}/v1/${projectIdPrefix}`;
  200. });
  201. }
  202. getProjectIdPrefix() {
  203. if (this.projectIdPrefix) {
  204. return Promise.resolve(this.projectIdPrefix);
  205. }
  206. return utils.findProjectId(this.app)
  207. .then((projectId) => {
  208. if (!validator.isNonEmptyString(projectId)) {
  209. throw new FirebaseRemoteConfigError('unknown-error', 'Failed to determine project ID. Initialize the SDK with service account credentials, or '
  210. + 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT '
  211. + 'environment variable.');
  212. }
  213. this.projectIdPrefix = `projects/${projectId}`;
  214. return this.projectIdPrefix;
  215. });
  216. }
  217. toFirebaseError(err) {
  218. if (err instanceof error_1.PrefixedFirebaseError) {
  219. return err;
  220. }
  221. const response = err.response;
  222. if (!response.isJson()) {
  223. return new FirebaseRemoteConfigError('unknown-error', `Unexpected response with status: ${response.status} and body: ${response.text}`);
  224. }
  225. const error = response.data.error || {};
  226. let code = 'unknown-error';
  227. if (error.status && error.status in ERROR_CODE_MAPPING) {
  228. code = ERROR_CODE_MAPPING[error.status];
  229. }
  230. const message = error.message || `Unknown server error: ${response.text}`;
  231. return new FirebaseRemoteConfigError(code, message);
  232. }
  233. /**
  234. * Creates a RemoteConfigTemplate from the API response.
  235. * If provided, customEtag is used instead of the etag returned in the API response.
  236. *
  237. * @param {HttpResponse} resp API response object.
  238. * @param {string} customEtag A custom etag to replace the etag fom the API response (Optional).
  239. */
  240. toRemoteConfigTemplate(resp, customEtag) {
  241. const etag = (typeof customEtag === 'undefined') ? resp.headers['etag'] : customEtag;
  242. this.validateEtag(etag);
  243. return {
  244. conditions: resp.data.conditions,
  245. parameters: resp.data.parameters,
  246. parameterGroups: resp.data.parameterGroups,
  247. etag,
  248. version: resp.data.version,
  249. };
  250. }
  251. /**
  252. * Creates a RemoteConfigServerTemplate from the API response.
  253. * If provided, customEtag is used instead of the etag returned in the API response.
  254. *
  255. * @param {HttpResponse} resp API response object.
  256. * @param {string} customEtag A custom etag to replace the etag fom the API response (Optional).
  257. */
  258. toRemoteConfigServerTemplate(resp, customEtag) {
  259. const etag = (typeof customEtag === 'undefined') ? resp.headers['etag'] : customEtag;
  260. this.validateEtag(etag);
  261. return {
  262. conditions: resp.data.conditions,
  263. parameters: resp.data.parameters,
  264. etag,
  265. version: resp.data.version,
  266. };
  267. }
  268. /**
  269. * Checks if the given RemoteConfigTemplate object is valid.
  270. * The object must have valid parameters, parameter groups, conditions, and an etag.
  271. * Removes output only properties from version metadata.
  272. *
  273. * @param {RemoteConfigTemplate} template A RemoteConfigTemplate object to be validated.
  274. *
  275. * @returns {RemoteConfigTemplate} The validated RemoteConfigTemplate object.
  276. */
  277. validateInputRemoteConfigTemplate(template) {
  278. const templateCopy = (0, deep_copy_1.deepCopy)(template);
  279. if (!validator.isNonNullObject(templateCopy)) {
  280. throw new FirebaseRemoteConfigError('invalid-argument', `Invalid Remote Config template: ${JSON.stringify(templateCopy)}`);
  281. }
  282. if (!validator.isNonEmptyString(templateCopy.etag)) {
  283. throw new FirebaseRemoteConfigError('invalid-argument', 'ETag must be a non-empty string.');
  284. }
  285. if (!validator.isNonNullObject(templateCopy.parameters)) {
  286. throw new FirebaseRemoteConfigError('invalid-argument', 'Remote Config parameters must be a non-null object');
  287. }
  288. if (!validator.isNonNullObject(templateCopy.parameterGroups)) {
  289. throw new FirebaseRemoteConfigError('invalid-argument', 'Remote Config parameter groups must be a non-null object');
  290. }
  291. if (!validator.isArray(templateCopy.conditions)) {
  292. throw new FirebaseRemoteConfigError('invalid-argument', 'Remote Config conditions must be an array');
  293. }
  294. if (typeof templateCopy.version !== 'undefined') {
  295. // exclude output only properties and keep the only input property: description
  296. templateCopy.version = { description: templateCopy.version.description };
  297. }
  298. return templateCopy;
  299. }
  300. /**
  301. * Checks if a given version number is valid.
  302. * A version number must be an integer or a string in int64 format.
  303. * If valid, returns the string representation of the provided version number.
  304. *
  305. * @param {string|number} versionNumber A version number to be validated.
  306. *
  307. * @returns {string} The validated version number as a string.
  308. */
  309. validateVersionNumber(versionNumber, propertyName = 'versionNumber') {
  310. if (!validator.isNonEmptyString(versionNumber) &&
  311. !validator.isNumber(versionNumber)) {
  312. throw new FirebaseRemoteConfigError('invalid-argument', `${propertyName} must be a non-empty string in int64 format or a number`);
  313. }
  314. if (!Number.isInteger(Number(versionNumber))) {
  315. throw new FirebaseRemoteConfigError('invalid-argument', `${propertyName} must be an integer or a string in int64 format`);
  316. }
  317. return versionNumber.toString();
  318. }
  319. validateEtag(etag) {
  320. if (!validator.isNonEmptyString(etag)) {
  321. throw new FirebaseRemoteConfigError('invalid-argument', 'ETag header is not present in the server response.');
  322. }
  323. }
  324. /**
  325. * Checks if a given `ListVersionsOptions` object is valid. If successful, creates a copy of the
  326. * options object and convert `startTime` and `endTime` to RFC3339 UTC "Zulu" format, if present.
  327. *
  328. * @param {ListVersionsOptions} options An options object to be validated.
  329. *
  330. * @returns {ListVersionsOptions} A copy of the provided options object with timestamps converted
  331. * to UTC Zulu format.
  332. */
  333. validateListVersionsOptions(options) {
  334. const optionsCopy = (0, deep_copy_1.deepCopy)(options);
  335. if (!validator.isNonNullObject(optionsCopy)) {
  336. throw new FirebaseRemoteConfigError('invalid-argument', 'ListVersionsOptions must be a non-null object.');
  337. }
  338. if (typeof optionsCopy.pageSize !== 'undefined') {
  339. if (!validator.isNumber(optionsCopy.pageSize)) {
  340. throw new FirebaseRemoteConfigError('invalid-argument', 'pageSize must be a number.');
  341. }
  342. if (optionsCopy.pageSize < 1 || optionsCopy.pageSize > 300) {
  343. throw new FirebaseRemoteConfigError('invalid-argument', 'pageSize must be a number between 1 and 300 (inclusive).');
  344. }
  345. }
  346. if (typeof optionsCopy.pageToken !== 'undefined' && !validator.isNonEmptyString(optionsCopy.pageToken)) {
  347. throw new FirebaseRemoteConfigError('invalid-argument', 'pageToken must be a string value.');
  348. }
  349. if (typeof optionsCopy.endVersionNumber !== 'undefined') {
  350. optionsCopy.endVersionNumber = this.validateVersionNumber(optionsCopy.endVersionNumber, 'endVersionNumber');
  351. }
  352. if (typeof optionsCopy.startTime !== 'undefined') {
  353. if (!(optionsCopy.startTime instanceof Date) && !validator.isUTCDateString(optionsCopy.startTime)) {
  354. throw new FirebaseRemoteConfigError('invalid-argument', 'startTime must be a valid Date object or a UTC date string.');
  355. }
  356. // Convert startTime to RFC3339 UTC "Zulu" format.
  357. if (optionsCopy.startTime instanceof Date) {
  358. optionsCopy.startTime = optionsCopy.startTime.toISOString();
  359. }
  360. else {
  361. optionsCopy.startTime = new Date(optionsCopy.startTime).toISOString();
  362. }
  363. }
  364. if (typeof optionsCopy.endTime !== 'undefined') {
  365. if (!(optionsCopy.endTime instanceof Date) && !validator.isUTCDateString(optionsCopy.endTime)) {
  366. throw new FirebaseRemoteConfigError('invalid-argument', 'endTime must be a valid Date object or a UTC date string.');
  367. }
  368. // Convert endTime to RFC3339 UTC "Zulu" format.
  369. if (optionsCopy.endTime instanceof Date) {
  370. optionsCopy.endTime = optionsCopy.endTime.toISOString();
  371. }
  372. else {
  373. optionsCopy.endTime = new Date(optionsCopy.endTime).toISOString();
  374. }
  375. }
  376. // Remove undefined fields from optionsCopy
  377. Object.keys(optionsCopy).forEach(key => (typeof optionsCopy[key] === 'undefined') && delete optionsCopy[key]);
  378. return optionsCopy;
  379. }
  380. }
  381. exports.RemoteConfigApiClient = RemoteConfigApiClient;
  382. const ERROR_CODE_MAPPING = {
  383. ABORTED: 'aborted',
  384. ALREADY_EXISTS: 'already-exists',
  385. INVALID_ARGUMENT: 'invalid-argument',
  386. INTERNAL: 'internal-error',
  387. FAILED_PRECONDITION: 'failed-precondition',
  388. NOT_FOUND: 'not-found',
  389. OUT_OF_RANGE: 'out-of-range',
  390. PERMISSION_DENIED: 'permission-denied',
  391. RESOURCE_EXHAUSTED: 'resource-exhausted',
  392. UNAUTHENTICATED: 'unauthenticated',
  393. UNKNOWN: 'unknown-error',
  394. };
  395. /**
  396. * Firebase Remote Config error code structure. This extends PrefixedFirebaseError.
  397. *
  398. * @param {RemoteConfigErrorCode} code The error code.
  399. * @param {string} message The error message.
  400. * @constructor
  401. */
  402. class FirebaseRemoteConfigError extends error_1.PrefixedFirebaseError {
  403. constructor(code, message) {
  404. super('remote-config', code, message);
  405. }
  406. }
  407. exports.FirebaseRemoteConfigError = FirebaseRemoteConfigError;