functions-api-client-internal.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. /*! firebase-admin v12.1.1 */
  2. "use strict";
  3. /*!
  4. * @license
  5. * Copyright 2021 Google Inc.
  6. *
  7. * Licensed under the Apache License, Version 2.0 (the "License");
  8. * you may not use this file except in compliance with the License.
  9. * You may obtain a copy of the License at
  10. *
  11. * http://www.apache.org/licenses/LICENSE-2.0
  12. *
  13. * Unless required by applicable law or agreed to in writing, software
  14. * distributed under the License is distributed on an "AS IS" BASIS,
  15. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  16. * See the License for the specific language governing permissions and
  17. * limitations under the License.
  18. */
  19. Object.defineProperty(exports, "__esModule", { value: true });
  20. exports.FirebaseFunctionsError = exports.FUNCTIONS_ERROR_CODE_MAPPING = exports.FunctionsApiClient = void 0;
  21. const api_request_1 = require("../utils/api-request");
  22. const error_1 = require("../utils/error");
  23. const utils = require("../utils/index");
  24. const validator = require("../utils/validator");
  25. const credential_internal_1 = require("../app/credential-internal");
  26. const CLOUD_TASKS_API_RESOURCE_PATH = 'projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks';
  27. const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/' + CLOUD_TASKS_API_RESOURCE_PATH;
  28. const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}';
  29. const FIREBASE_FUNCTIONS_CONFIG_HEADERS = {
  30. 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
  31. };
  32. // Default canonical location ID of the task queue.
  33. const DEFAULT_LOCATION = 'us-central1';
  34. /**
  35. * Class that facilitates sending requests to the Firebase Functions backend API.
  36. *
  37. * @internal
  38. */
  39. class FunctionsApiClient {
  40. constructor(app) {
  41. this.app = app;
  42. if (!validator.isNonNullObject(app) || !('options' in app)) {
  43. throw new FirebaseFunctionsError('invalid-argument', 'First argument passed to getFunctions() must be a valid Firebase app instance.');
  44. }
  45. this.httpClient = new api_request_1.AuthorizedHttpClient(app);
  46. }
  47. /**
  48. * Deletes a task from a queue.
  49. *
  50. * @param id - The ID of the task to delete.
  51. * @param functionName - The function name of the queue.
  52. * @param extensionId - Optional canonical ID of the extension.
  53. */
  54. async delete(id, functionName, extensionId) {
  55. if (!validator.isNonEmptyString(functionName)) {
  56. throw new FirebaseFunctionsError('invalid-argument', 'Function name must be a non empty string');
  57. }
  58. if (!validator.isTaskId(id)) {
  59. throw new FirebaseFunctionsError('invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
  60. + 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
  61. }
  62. let resources;
  63. try {
  64. resources = utils.parseResourceName(functionName, 'functions');
  65. }
  66. catch (err) {
  67. throw new FirebaseFunctionsError('invalid-argument', 'Function name must be a single string or a qualified resource name');
  68. }
  69. resources.projectId = resources.projectId || await this.getProjectId();
  70. resources.locationId = resources.locationId || DEFAULT_LOCATION;
  71. if (!validator.isNonEmptyString(resources.resourceId)) {
  72. throw new FirebaseFunctionsError('invalid-argument', 'No valid function name specified to enqueue tasks for.');
  73. }
  74. if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) {
  75. resources.resourceId = `ext-${extensionId}-${resources.resourceId}`;
  76. }
  77. try {
  78. const serviceUrl = await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT.concat('/', id));
  79. const request = {
  80. method: 'DELETE',
  81. url: serviceUrl,
  82. headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
  83. };
  84. await this.httpClient.send(request);
  85. }
  86. catch (err) {
  87. if (err instanceof api_request_1.HttpError) {
  88. if (err.response.status === 404) {
  89. // if no task with the provided ID exists, then ignore the delete.
  90. return;
  91. }
  92. throw this.toFirebaseError(err);
  93. }
  94. else {
  95. throw err;
  96. }
  97. }
  98. }
  99. /**
  100. * Creates a task and adds it to a queue.
  101. *
  102. * @param data - The data payload of the task.
  103. * @param functionName - The functionName of the queue.
  104. * @param extensionId - Optional canonical ID of the extension.
  105. * @param opts - Optional options when enqueuing a new task.
  106. */
  107. async enqueue(data, functionName, extensionId, opts) {
  108. if (!validator.isNonEmptyString(functionName)) {
  109. throw new FirebaseFunctionsError('invalid-argument', 'Function name must be a non empty string');
  110. }
  111. let resources;
  112. try {
  113. resources = utils.parseResourceName(functionName, 'functions');
  114. }
  115. catch (err) {
  116. throw new FirebaseFunctionsError('invalid-argument', 'Function name must be a single string or a qualified resource name');
  117. }
  118. resources.projectId = resources.projectId || await this.getProjectId();
  119. resources.locationId = resources.locationId || DEFAULT_LOCATION;
  120. if (!validator.isNonEmptyString(resources.resourceId)) {
  121. throw new FirebaseFunctionsError('invalid-argument', 'No valid function name specified to enqueue tasks for.');
  122. }
  123. if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) {
  124. resources.resourceId = `ext-${extensionId}-${resources.resourceId}`;
  125. }
  126. const task = this.validateTaskOptions(data, resources, opts);
  127. try {
  128. const serviceUrl = await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT);
  129. const taskPayload = await this.updateTaskPayload(task, resources, extensionId);
  130. const request = {
  131. method: 'POST',
  132. url: serviceUrl,
  133. headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
  134. data: {
  135. task: taskPayload,
  136. }
  137. };
  138. await this.httpClient.send(request);
  139. }
  140. catch (err) {
  141. if (err instanceof api_request_1.HttpError) {
  142. if (err.response.status === 409) {
  143. throw new FirebaseFunctionsError('task-already-exists', `A task with ID ${opts?.id} already exists`);
  144. }
  145. else {
  146. throw this.toFirebaseError(err);
  147. }
  148. }
  149. else {
  150. throw err;
  151. }
  152. }
  153. }
  154. getUrl(resourceName, urlFormat) {
  155. let { locationId } = resourceName;
  156. const { projectId, resourceId } = resourceName;
  157. if (typeof locationId === 'undefined' || !validator.isNonEmptyString(locationId)) {
  158. locationId = DEFAULT_LOCATION;
  159. }
  160. return Promise.resolve()
  161. .then(() => {
  162. if (typeof projectId !== 'undefined' && validator.isNonEmptyString(projectId)) {
  163. return projectId;
  164. }
  165. return this.getProjectId();
  166. })
  167. .then((projectId) => {
  168. const urlParams = {
  169. projectId,
  170. locationId,
  171. resourceId,
  172. };
  173. // Formats a string of form 'project/{projectId}/{api}' and replaces
  174. // with corresponding arguments {projectId: '1234', api: 'resource'}
  175. // and returns output: 'project/1234/resource'.
  176. return utils.formatString(urlFormat, urlParams);
  177. });
  178. }
  179. getProjectId() {
  180. if (this.projectId) {
  181. return Promise.resolve(this.projectId);
  182. }
  183. return utils.findProjectId(this.app)
  184. .then((projectId) => {
  185. if (!validator.isNonEmptyString(projectId)) {
  186. throw new FirebaseFunctionsError('unknown-error', 'Failed to determine project ID. Initialize the '
  187. + 'SDK with service account credentials or set project ID as an app option. '
  188. + 'Alternatively, set the GOOGLE_CLOUD_PROJECT environment variable.');
  189. }
  190. this.projectId = projectId;
  191. return projectId;
  192. });
  193. }
  194. getServiceAccount() {
  195. if (this.accountId) {
  196. return Promise.resolve(this.accountId);
  197. }
  198. return utils.findServiceAccountEmail(this.app)
  199. .then((accountId) => {
  200. if (!validator.isNonEmptyString(accountId)) {
  201. throw new FirebaseFunctionsError('unknown-error', 'Failed to determine service account. Initialize the '
  202. + 'SDK with service account credentials or set service account ID as an app option.');
  203. }
  204. this.accountId = accountId;
  205. return accountId;
  206. });
  207. }
  208. validateTaskOptions(data, resources, opts) {
  209. const task = {
  210. httpRequest: {
  211. url: '',
  212. oidcToken: {
  213. serviceAccountEmail: '',
  214. },
  215. body: Buffer.from(JSON.stringify({ data })).toString('base64'),
  216. headers: {
  217. 'Content-Type': 'application/json',
  218. ...opts?.headers,
  219. }
  220. }
  221. };
  222. if (typeof opts !== 'undefined') {
  223. if (!validator.isNonNullObject(opts)) {
  224. throw new FirebaseFunctionsError('invalid-argument', 'TaskOptions must be a non-null object');
  225. }
  226. if ('scheduleTime' in opts && 'scheduleDelaySeconds' in opts) {
  227. throw new FirebaseFunctionsError('invalid-argument', 'Both scheduleTime and scheduleDelaySeconds are provided. '
  228. + 'Only one value should be set.');
  229. }
  230. if ('scheduleTime' in opts && typeof opts.scheduleTime !== 'undefined') {
  231. if (!(opts.scheduleTime instanceof Date)) {
  232. throw new FirebaseFunctionsError('invalid-argument', 'scheduleTime must be a valid Date object.');
  233. }
  234. task.scheduleTime = opts.scheduleTime.toISOString();
  235. }
  236. if ('scheduleDelaySeconds' in opts && typeof opts.scheduleDelaySeconds !== 'undefined') {
  237. if (!validator.isNumber(opts.scheduleDelaySeconds) || opts.scheduleDelaySeconds < 0) {
  238. throw new FirebaseFunctionsError('invalid-argument', 'scheduleDelaySeconds must be a non-negative duration in seconds.');
  239. }
  240. const date = new Date();
  241. date.setSeconds(date.getSeconds() + opts.scheduleDelaySeconds);
  242. task.scheduleTime = date.toISOString();
  243. }
  244. if (typeof opts.dispatchDeadlineSeconds !== 'undefined') {
  245. if (!validator.isNumber(opts.dispatchDeadlineSeconds) || opts.dispatchDeadlineSeconds < 15
  246. || opts.dispatchDeadlineSeconds > 1800) {
  247. throw new FirebaseFunctionsError('invalid-argument', 'dispatchDeadlineSeconds must be a non-negative duration in seconds '
  248. + 'and must be in the range of 15s to 30 mins.');
  249. }
  250. task.dispatchDeadline = `${opts.dispatchDeadlineSeconds}s`;
  251. }
  252. if ('id' in opts && typeof opts.id !== 'undefined') {
  253. if (!validator.isTaskId(opts.id)) {
  254. throw new FirebaseFunctionsError('invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
  255. + 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
  256. }
  257. const resourcePath = utils.formatString(CLOUD_TASKS_API_RESOURCE_PATH, {
  258. projectId: resources.projectId,
  259. locationId: resources.locationId,
  260. resourceId: resources.resourceId,
  261. });
  262. task.name = resourcePath.concat('/', opts.id);
  263. }
  264. if (typeof opts.uri !== 'undefined') {
  265. if (!validator.isURL(opts.uri)) {
  266. throw new FirebaseFunctionsError('invalid-argument', 'uri must be a valid URL string.');
  267. }
  268. task.httpRequest.url = opts.uri;
  269. }
  270. }
  271. return task;
  272. }
  273. async updateTaskPayload(task, resources, extensionId) {
  274. const functionUrl = validator.isNonEmptyString(task.httpRequest.url)
  275. ? task.httpRequest.url
  276. : await this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT);
  277. task.httpRequest.url = functionUrl;
  278. // When run from a deployed extension, we should be using ComputeEngineCredentials
  279. if (validator.isNonEmptyString(extensionId) && this.app.options.credential instanceof credential_internal_1.ComputeEngineCredential) {
  280. const idToken = await this.app.options.credential.getIDToken(functionUrl);
  281. task.httpRequest.headers = { ...task.httpRequest.headers, 'Authorization': `Bearer ${idToken}` };
  282. // Don't send httpRequest.oidcToken if we set Authorization header, or Cloud Tasks will overwrite it.
  283. delete task.httpRequest.oidcToken;
  284. }
  285. else {
  286. const account = await this.getServiceAccount();
  287. task.httpRequest.oidcToken = { serviceAccountEmail: account };
  288. }
  289. return task;
  290. }
  291. toFirebaseError(err) {
  292. if (err instanceof error_1.PrefixedFirebaseError) {
  293. return err;
  294. }
  295. const response = err.response;
  296. if (!response.isJson()) {
  297. return new FirebaseFunctionsError('unknown-error', `Unexpected response with status: ${response.status} and body: ${response.text}`);
  298. }
  299. const error = response.data.error || {};
  300. let code = 'unknown-error';
  301. if (error.status && error.status in exports.FUNCTIONS_ERROR_CODE_MAPPING) {
  302. code = exports.FUNCTIONS_ERROR_CODE_MAPPING[error.status];
  303. }
  304. const message = error.message || `Unknown server error: ${response.text}`;
  305. return new FirebaseFunctionsError(code, message);
  306. }
  307. }
  308. exports.FunctionsApiClient = FunctionsApiClient;
  309. exports.FUNCTIONS_ERROR_CODE_MAPPING = {
  310. ABORTED: 'aborted',
  311. INVALID_ARGUMENT: 'invalid-argument',
  312. INVALID_CREDENTIAL: 'invalid-credential',
  313. INTERNAL: 'internal-error',
  314. FAILED_PRECONDITION: 'failed-precondition',
  315. PERMISSION_DENIED: 'permission-denied',
  316. UNAUTHENTICATED: 'unauthenticated',
  317. NOT_FOUND: 'not-found',
  318. UNKNOWN: 'unknown-error',
  319. };
  320. /**
  321. * Firebase Functions error code structure. This extends PrefixedFirebaseError.
  322. *
  323. * @param code - The error code.
  324. * @param message - The error message.
  325. * @constructor
  326. */
  327. class FirebaseFunctionsError extends error_1.PrefixedFirebaseError {
  328. constructor(code, message) {
  329. super('functions', code, message);
  330. /* tslint:disable:max-line-length */
  331. // Set the prototype explicitly. See the following link for more details:
  332. // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
  333. /* tslint:enable:max-line-length */
  334. this.__proto__ = FirebaseFunctionsError.prototype;
  335. }
  336. }
  337. exports.FirebaseFunctionsError = FirebaseFunctionsError;