From 6537a518fa19a6f869ceb1c78a96207644e798d2 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 26 Apr 2022 14:57:47 -0400 Subject: [PATCH 1/3] Add Task Queue Functions API (#93) * Add Functions API * PR fixes * Refactor internal Functions implementation * Adding unit tests first pass * Complete unit tests, add integration tests * rename service account email helper * Add support for partial resource names * Update unit tests * Make url public for beta * PR fixes * Fix docstrings * remove validation for data payload * fix docs * minor fixes * fixed a capitalization creep --- entrypoints.json | 4 + etc/firebase-admin.functions.api.md | 56 +++ package.json | 7 + src/app/credential-internal.ts | 21 + .../functions-api-client-internal.ts | 334 +++++++++++++ src/functions/functions-api.ts | 71 +++ src/functions/functions.ts | 102 ++++ src/functions/index.ts | 73 +++ src/utils/index.ts | 84 ++++ test/integration/functions.spec.ts | 43 ++ .../integration/postcheck/esm/example.test.js | 7 + .../typescript/example-modular.test.ts | 7 + .../functions-api-client-internal.spec.ts | 447 ++++++++++++++++++ test/unit/functions/functions.spec.ts | 187 ++++++++ test/unit/functions/index.spec.ts | 76 +++ test/unit/index.spec.ts | 4 + test/unit/utils/index.spec.ts | 26 +- 17 files changed, 1548 insertions(+), 1 deletion(-) create mode 100644 etc/firebase-admin.functions.api.md create mode 100644 src/functions/functions-api-client-internal.ts create mode 100644 src/functions/functions-api.ts create mode 100644 src/functions/functions.ts create mode 100644 src/functions/index.ts create mode 100644 test/integration/functions.spec.ts create mode 100644 test/unit/functions/functions-api-client-internal.spec.ts create mode 100644 test/unit/functions/functions.spec.ts create mode 100644 test/unit/functions/index.spec.ts diff --git a/entrypoints.json b/entrypoints.json index 55f2d766b2..6b507911ae 100644 --- a/entrypoints.json +++ b/entrypoints.json @@ -24,6 +24,10 @@ "typings": "./lib/firestore/index.d.ts", "dist": "./lib/firestore/index.js" }, + "firebase-admin/functions": { + "typings": "./lib/functions/index.d.ts", + "dist": "./lib/functions/index.js" + }, "firebase-admin/installations": { "typings": "./lib/installations/index.d.ts", "dist": "./lib/installations/index.js" diff --git a/etc/firebase-admin.functions.api.md b/etc/firebase-admin.functions.api.md new file mode 100644 index 0000000000..2ed05d6f60 --- /dev/null +++ b/etc/firebase-admin.functions.api.md @@ -0,0 +1,56 @@ +## API Report File for "firebase-admin.functions" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +/// + +import { Agent } from 'http'; + +// @public +export interface AbsoluteDelivery { + // @alpha (undocumented) + scheduleDelaySeconds?: never; + scheduleTime?: Date; +} + +// @public +export interface DelayDelivery { + scheduleDelaySeconds?: number; + // @alpha (undocumented) + scheduleTime?: never; +} + +// @public +export type DeliverySchedule = DelayDelivery | AbsoluteDelivery; + +// @public +export class Functions { + // Warning: (ae-forgotten-export) The symbol "App" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly app: App; + taskQueue>(functionName: string, extensionId?: string): TaskQueue; +} + +// @public +export function getFunctions(app?: App): Functions; + +// @public +export type TaskOptions = DeliverySchedule & TaskOptionsExperimental & { + dispatchDeadlineSeconds?: number; +}; + +// @public +export interface TaskOptionsExperimental { + // @beta + uri?: string; +} + +// @public +export class TaskQueue> { + enqueue(data: Args, opts?: TaskOptions): Promise; +} + +``` diff --git a/package.json b/package.json index f0cd1c6e3f..e5e08f7829 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,9 @@ "firestore": [ "lib/firestore" ], + "functions": [ + "lib/functions" + ], "installations": [ "lib/installations" ], @@ -132,6 +135,10 @@ "require": "./lib/firestore/index.js", "import": "./lib/esm/firestore/index.js" }, + "./functions": { + "require": "./lib/functions/index.js", + "import": "./lib/esm/functions/index.js" + }, "./installations": { "require": "./lib/installations/index.js", "import": "./lib/esm/installations/index.js" diff --git a/src/app/credential-internal.ts b/src/app/credential-internal.ts index 0817236038..28442ba8f7 100644 --- a/src/app/credential-internal.ts +++ b/src/app/credential-internal.ts @@ -33,6 +33,7 @@ const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; +const GOOGLE_METADATA_SERVICE_ACCOUNT_ID_PATH = '/computeMetadata/v1/instance/service-accounts/default/email'; const configDir = (() => { // Windows has a dedicated low-rights location for apps at ~/Application Data @@ -197,6 +198,7 @@ export class ComputeEngineCredential implements Credential { private readonly httpClient = new HttpClient(); private readonly httpAgent?: Agent; private projectId?: string; + private accountId?: string; constructor(httpAgent?: Agent) { this.httpAgent = httpAgent; @@ -226,6 +228,25 @@ export class ComputeEngineCredential implements Credential { }); } + public getServiceAccountEmail(): Promise { + if (this.accountId) { + return Promise.resolve(this.accountId); + } + + const request = this.buildRequest(GOOGLE_METADATA_SERVICE_ACCOUNT_ID_PATH); + return this.httpClient.send(request) + .then((resp) => { + this.accountId = resp.text!; + return this.accountId; + }) + .catch((err) => { + const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + `Failed to determine service account email: ${detail}`); + }); + } + private buildRequest(urlPath: string): HttpRequestConfig { return { method: 'GET', diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts new file mode 100644 index 0000000000..8bcd7bfd2f --- /dev/null +++ b/src/functions/functions-api-client-internal.ts @@ -0,0 +1,334 @@ +/*! + * @license + * Copyright 2021 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 { App } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { + HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient +} from '../utils/api-request'; +import { PrefixedFirebaseError } from '../utils/error'; +import * as utils from '../utils/index'; +import * as validator from '../utils/validator'; +import { TaskOptions } from './functions-api'; + +const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks'; +const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}'; + +const FIREBASE_FUNCTIONS_CONFIG_HEADERS = { + 'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}` +}; + +// Default canonical location ID of the task queue. +const DEFAULT_LOCATION = 'us-central1'; + +/** + * Class that facilitates sending requests to the Firebase Functions backend API. + * + * @internal + */ +export class FunctionsApiClient { + private readonly httpClient: HttpClient; + private projectId?: string; + private accountId?: string; + + constructor(private readonly app: App) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseFunctionsError( + 'invalid-argument', + 'First argument passed to getFunctions() must be a valid Firebase app instance.'); + } + this.httpClient = new AuthorizedHttpClient(app as FirebaseApp); + } + + /** + * Creates a task and adds it to a queue. + * + * @param data - The data payload of the task. + * @param functionName - The functionName of the queue. + * @param extensionId - Optional canonical ID of the extension. + * @param opts - Optional options when enqueuing a new task. + */ + public enqueue(data: any, functionName: string, extensionId?: string, opts?: TaskOptions): Promise { + if (!validator.isNonEmptyString(functionName)) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'Function name must be a non empty string'); + } + + const task = this.validateTaskOptions(data, opts); + let resources: utils.ParsedResource; + try { + resources = utils.parseResourceName(functionName, 'functions'); + } + catch (err) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'Function name must be a single string or a qualified resource name'); + } + + if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) { + resources.resourceId = `ext-${extensionId}-${resources.resourceId}`; + } + + return this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT) + .then((serviceUrl) => { + return this.updateTaskPayload(task, resources) + .then((task) => { + const request: HttpRequestConfig = { + method: 'POST', + url: serviceUrl, + headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS, + data: { + task, + } + }; + return this.httpClient.send(request); + }) + }) + .then(() => { + return; + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + private getUrl(resourceName: utils.ParsedResource, UrlFormat: string): Promise { + let { locationId } = resourceName; + const { projectId, resourceId } = resourceName; + if (typeof locationId === 'undefined' || !validator.isNonEmptyString(locationId)) { + locationId = DEFAULT_LOCATION; + } + return Promise.resolve() + .then(() => { + if (typeof projectId !== 'undefined' && validator.isNonEmptyString(projectId)) { + return projectId; + } + return this.getProjectId(); + }) + .then((projectId) => { + const urlParams = { + projectId, + locationId, + resourceId, + }; + // Formats a string of form 'project/{projectId}/{api}' and replaces + // with corresponding arguments {projectId: '1234', api: 'resource'} + // and returns output: 'project/1234/resource'. + return utils.formatString(UrlFormat, urlParams); + }); + } + + private getProjectId(): Promise { + if (this.projectId) { + return Promise.resolve(this.projectId); + } + return utils.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseFunctionsError( + '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.projectId = projectId; + return projectId; + }); + } + + private getServiceAccount(): Promise { + if (this.accountId) { + return Promise.resolve(this.accountId); + } + return utils.findServiceAccountEmail(this.app) + .then((accountId) => { + if (!validator.isNonEmptyString(accountId)) { + throw new FirebaseFunctionsError( + 'unknown-error', + 'Failed to determine service account. Initialize the ' + + 'SDK with service account credentials or set service account ID as an app option.'); + } + this.accountId = accountId; + return accountId; + }); + } + + private validateTaskOptions(data: any, opts?: TaskOptions): Task { + const task: Task = { + httpRequest: { + url: '', + oidcToken: { + serviceAccountEmail: '', + }, + body: Buffer.from(JSON.stringify({ data })).toString('base64'), + headers: { 'Content-Type': 'application/json' } + } + } + + if (typeof opts !== 'undefined') { + if (!validator.isNonNullObject(opts)) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'TaskOptions must be a non-null object'); + } + if ('scheduleTime' in opts && 'scheduleDelaySeconds' in opts) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'Both scheduleTime and scheduleDelaySeconds are provided. ' + + 'Only one value should be set.'); + } + if ('scheduleTime' in opts && typeof opts.scheduleTime !== 'undefined') { + if (!(opts.scheduleTime instanceof Date)) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'scheduleTime must be a valid Date object.'); + } + task.scheduleTime = opts.scheduleTime.toISOString(); + } + if ('scheduleDelaySeconds' in opts && typeof opts.scheduleDelaySeconds !== 'undefined') { + if (!validator.isNumber(opts.scheduleDelaySeconds) || opts.scheduleDelaySeconds < 0) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'scheduleDelaySeconds must be a non-negative duration in seconds.'); + } + const date = new Date(); + date.setSeconds(date.getSeconds() + opts.scheduleDelaySeconds); + task.scheduleTime = date.toISOString(); + } + if (typeof opts.dispatchDeadlineSeconds !== 'undefined') { + if (!validator.isNumber(opts.dispatchDeadlineSeconds) || opts.dispatchDeadlineSeconds < 15 + || opts.dispatchDeadlineSeconds > 1800) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'dispatchDeadlineSeconds must be a non-negative duration in seconds ' + + 'and must be in the range of 15s to 30 mins.'); + } + task.dispatchDeadline = `${opts.dispatchDeadlineSeconds}s`; + } + if (typeof opts.uri !== 'undefined') { + if (!validator.isURL(opts.uri)) { + throw new FirebaseFunctionsError( + 'invalid-argument', 'uri must be a valid URL string.'); + } + task.httpRequest.url = opts.uri; + } + } + return task; + } + + private updateTaskPayload(task: Task, resources: utils.ParsedResource): Promise { + return Promise.resolve() + .then(() => { + if (validator.isNonEmptyString(task.httpRequest.url)) { + return task.httpRequest.url; + } + return this.getUrl(resources, FIREBASE_FUNCTION_URL_FORMAT); + }) + .then((functionUrl) => { + return this.getServiceAccount() + .then((account) => { + task.httpRequest.oidcToken.serviceAccountEmail = account; + task.httpRequest.url = functionUrl; + return task; + }) + }); + } + + private toFirebaseError(err: HttpError): PrefixedFirebaseError { + if (err instanceof PrefixedFirebaseError) { + return err; + } + + const response = err.response; + if (!response.isJson()) { + return new FirebaseFunctionsError( + 'unknown-error', + `Unexpected response with status: ${response.status} and body: ${response.text}`); + } + + const error: Error = (response.data as ErrorResponse).error || {}; + let code: FunctionsErrorCode = 'unknown-error'; + if (error.status && error.status in FUNCTIONS_ERROR_CODE_MAPPING) { + code = FUNCTIONS_ERROR_CODE_MAPPING[error.status]; + } + const message = error.message || `Unknown server error: ${response.text}`; + return new FirebaseFunctionsError(code, message); + } +} + +interface ErrorResponse { + error?: Error; +} + +interface Error { + code?: number; + message?: string; + status?: string; +} + +interface Task { + // A timestamp in RFC3339 UTC "Zulu" format, with nanosecond resolution and up to nine fractional + // digits. Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045123456Z". + scheduleTime?: string; + // A duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + dispatchDeadline?: string; + httpRequest: { + url: string; + oidcToken: { + serviceAccountEmail: string; + }; + // A base64-encoded string. + body: string; + headers: { [key: string]: string }; + }; +} + +export const FUNCTIONS_ERROR_CODE_MAPPING: { [key: string]: FunctionsErrorCode } = { + ABORTED: 'aborted', + INVALID_ARGUMENT: 'invalid-argument', + INVALID_CREDENTIAL: 'invalid-credential', + INTERNAL: 'internal-error', + FAILED_PRECONDITION: 'failed-precondition', + PERMISSION_DENIED: 'permission-denied', + UNAUTHENTICATED: 'unauthenticated', + NOT_FOUND: 'not-found', + UNKNOWN: 'unknown-error', +}; + +export type FunctionsErrorCode = + 'aborted' + | 'invalid-argument' + | 'invalid-credential' + | 'internal-error' + | 'failed-precondition' + | 'permission-denied' + | 'unauthenticated' + | 'not-found' + | 'unknown-error'; + +/** + * Firebase Functions error code structure. This extends PrefixedFirebaseError. + * + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class FirebaseFunctionsError extends PrefixedFirebaseError { + constructor(code: FunctionsErrorCode, message: string) { + super('functions', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseFunctionsError.prototype; + } +} diff --git a/src/functions/functions-api.ts b/src/functions/functions-api.ts new file mode 100644 index 0000000000..1383495aa7 --- /dev/null +++ b/src/functions/functions-api.ts @@ -0,0 +1,71 @@ +/*! + * @license + * Copyright 2021 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. + */ + +/** + * Interface representing task options with delayed delivery. + */ +export interface DelayDelivery { + /** + * The duration of delay of the time when the task is scheduled to be attempted or retried. + * This delay is added to the current time. + */ + scheduleDelaySeconds?: number; + /** @alpha */ + scheduleTime?: never; +} + +/** + * Interface representing task options with absolute delivery. + */ +export interface AbsoluteDelivery { + /** + * The time when the task is scheduled to be attempted or retried. + */ + scheduleTime?: Date; + /** @alpha */ + scheduleDelaySeconds?: never; +} + +/** + * Type representing delivery schedule options. + */ +export type DeliverySchedule = DelayDelivery | AbsoluteDelivery + +/** + * Type representing task options. + */ +export type TaskOptions = DeliverySchedule & TaskOptionsExperimental & { + + /** + * The deadline for requests sent to the worker. If the worker does not respond by this deadline + * then the request is cancelled and the attempt is marked as a DEADLINE_EXCEEDED failure. + * Cloud Tasks will retry the task according to the `RetryConfig`. + * The default is 10 minutes. The deadline must be in the range of 15 seconds and 30 minutes. + */ + dispatchDeadlineSeconds?: number; +} + +/** + * Type representing experimental (beta) task options. + */ +export interface TaskOptionsExperimental { + /** + * The full URL path that the request will be sent to. Must be a valid URL. + * @beta + */ + uri?: string; +} diff --git a/src/functions/functions.ts b/src/functions/functions.ts new file mode 100644 index 0000000000..648f593297 --- /dev/null +++ b/src/functions/functions.ts @@ -0,0 +1,102 @@ +/*! + * @license + * Copyright 2021 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 { App } from '../app'; +import { FirebaseFunctionsError, FunctionsApiClient } from './functions-api-client-internal'; +import { TaskOptions } from './functions-api'; +import * as validator from '../utils/validator'; + +/** + * The Firebase `Functions` service interface. + */ +export class Functions { + + private readonly client: FunctionsApiClient; + + /** + * @param app - The app for this `Functions` service. + * @constructor + * @internal + */ + constructor(readonly app: App) { + this.client = new FunctionsApiClient(app); + } + + /** + * Creates a reference to a {@link TaskQueue} for a given function name. + * The function name can be either: + * * A fully qualified function resource name: + * `projects/{project}/locations/{location}/functions/{functionName}` + * * A partial resource name with location and function name, in which case + * the runtime project ID is used: + * `locations/{location}/functions/{functionName}` + * * A partial function name, in which case the runtime project ID and the default location, + * `us-central1`, is used: + * `{functionName}` + * + * @param functionName - The name of the function. + * @param extensionId - Optional Firebase extension ID. + * @returns A promise that fulfills with a `TaskQueue`. + */ + public taskQueue>(functionName: string, extensionId?: string): TaskQueue { + return new TaskQueue(functionName, this.client, extensionId); + } +} + +/** + * The `TaskQueue` interface. + */ +export class TaskQueue> { + + /** + * @param functionName - The name of the function. + * @param client - The `FunctionsApiClient` instance. + * @param extensionId - Optional canonical ID of the extension. + * @constructor + * @internal + */ + constructor(private readonly functionName: string, private readonly client: FunctionsApiClient, + private readonly extensionId?: string) { + if (!validator.isNonEmptyString(functionName)) { + throw new FirebaseFunctionsError( + 'invalid-argument', + '`functionName` must be a non-empty string.'); + } + if (!validator.isNonNullObject(client) || !('enqueue' in client)) { + throw new FirebaseFunctionsError( + 'invalid-argument', + 'Must provide a valid FunctionsApiClient instance to create a new TaskQueue.'); + } + if (typeof extensionId !== 'undefined' && !validator.isString(extensionId)) { + throw new FirebaseFunctionsError( + 'invalid-argument', + '`extensionId` must be a string.'); + } + } + + /** + * Creates a task and adds it to the queue. Tasks cannot be updated after creation. + * This action requires `cloudtasks.tasks.create` IAM permission on the service account. + * + * @param data - The data payload of the task. + * @param opts - Optional options when enqueuing a new task. + * @returns A promise that resolves when the task has successfully been added to the queue. + */ + public enqueue(data: Args, opts?: TaskOptions): Promise { + return this.client.enqueue(data, this.functionName, this.extensionId, opts); + } +} diff --git a/src/functions/index.ts b/src/functions/index.ts new file mode 100644 index 0000000000..a046c4dd93 --- /dev/null +++ b/src/functions/index.ts @@ -0,0 +1,73 @@ +/*! + * @license + * Copyright 2021 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. + */ + +/** + * Firebase Functions service. + * + * @packageDocumentation + */ + +import { App, getApp } from '../app'; +import { FirebaseApp } from '../app/firebase-app'; +import { Functions } from './functions'; + +export { + DelayDelivery, + AbsoluteDelivery, + DeliverySchedule, + TaskOptions, + TaskOptionsExperimental +} from './functions-api'; +export { + Functions, + TaskQueue +} from './functions'; + +/** + * Gets the {@link Functions} service for the default app + * or a given app. + * + * `getFunctions()` can be called with no arguments to access the default + * app's `Functions` service or as `getFunctions(app)` to access the + * `Functions` service associated with a specific app. + * + * @example + * ```javascript + * // Get the `Functions` service for the default app + * const defaultFunctions = getFunctions(); + * ``` + * + * @example + * ```javascript + * // Get the `Functions` service for a given app + * const otherFunctions = getFunctions(otherApp); + * ``` + * + * @param app - Optional app for which to return the `Functions` service. + * If not provided, the default `Functions` service is returned. + * + * @returns The default `Functions` service if no app is provided, or the `Functions` + * service associated with the provided app. + */ +export function getFunctions(app?: App): Functions { + if (typeof app === 'undefined') { + app = getApp(); + } + + const firebaseApp: FirebaseApp = app as FirebaseApp; + return firebaseApp.getOrInitService('functions', (app) => new Functions(app)); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index ed9b2bc80e..824d41c0f9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -120,6 +120,53 @@ export function findProjectId(app: App): Promise { return Promise.resolve(null); } +/** + * Returns the service account email associated with a Firebase app, if it's explicitly + * specified in either the Firebase app options, credentials or the local environment. + * Otherwise returns null. + * + * @param app - A Firebase app to get the service account email from. + * + * @returns A service account email string or null. + */ +export function getExplicitServiceAccountEmail(app: App): string | null { + const options = app.options; + if (validator.isNonEmptyString(options.serviceAccountId)) { + return options.serviceAccountId; + } + + const credential = app.options.credential; + if (credential instanceof ServiceAccountCredential) { + return credential.clientEmail; + } + return null; +} + +/** + * Determines the service account email associated with a Firebase app. This method first + * checks if a service account email is explicitly specified in either the Firebase app options, + * credentials or the local environment in that order. If no explicit service account email is + * configured, but the SDK has been initialized with ComputeEngineCredentials, this + * method attempts to discover the service account email from the local metadata service. + * + * @param app - A Firebase app to get the service account email from. + * + * @returns A service account email ID string or null. + */ +export function findServiceAccountEmail(app: App): Promise { + const accountId = getExplicitServiceAccountEmail(app); + if (accountId) { + return Promise.resolve(accountId); + } + + const credential = app.options.credential; + if (credential instanceof ComputeEngineCredential) { + return credential.getServiceAccountEmail(); + } + + return Promise.resolve(null); +} + /** * Encodes data using web-safe-base64. * @@ -217,3 +264,40 @@ export function transformMillisecondsToSecondsString(milliseconds: number): stri } return duration; } + +/** + * Internal type to represent a resource name + */ +export type ParsedResource = { + projectId?: string; + locationId?: string; + resourceId: string; +} + +/** + * Parses the top level resources of a given resource name. + * Supports both full and partial resources names, example: + * `locations/{location}/functions/{functionName}`, + * `projects/{project}/locations/{location}/functions/{functionName}`, or {functionName} + * Does not support deeply nested resource names. + * + * @param resourceName - The resource name string. + * @param resourceIdKey - The key of the resource name to be parsed. + * @returns A parsed resource name object. + */ +export function parseResourceName(resourceName: string, resourceIdKey: string): ParsedResource { + if (!resourceName.includes('/')) { + return { resourceId: resourceName }; + } + const CHANNEL_NAME_REGEX = + new RegExp(`^(projects/([^/]+)/)?locations/([^/]+)/${resourceIdKey}/([^/]+)$`); + const match = CHANNEL_NAME_REGEX.exec(resourceName); + if (match === null) { + throw new Error('Invalid resource name format.'); + } + const projectId = match[2]; + const locationId = match[3]; + const resourceId = match[4]; + + return { projectId, locationId, resourceId }; +} diff --git a/test/integration/functions.spec.ts b/test/integration/functions.spec.ts new file mode 100644 index 0000000000..0385289dbb --- /dev/null +++ b/test/integration/functions.spec.ts @@ -0,0 +1,43 @@ +/*! + * Copyright 2022 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 * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { getFunctions } from '../../lib/functions/index'; + +chai.should(); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('getFunctions()', () => { + + describe('taskQueue()', () => { + it('successfully returns a taskQueue', () => { + const factorizeQueue = getFunctions().taskQueue('queue-name'); + expect(factorizeQueue).to.be.not.undefined; + expect(typeof factorizeQueue.enqueue).to.equal('function'); + }); + }); + + describe('enqueue()', () => { + it('should propagate API errors', () => { + // rejects with failed-precondition when the queue does not exist + return getFunctions().taskQueue('non-existing-queue').enqueue({}) + .should.eventually.be.rejected.and.have.property('code', 'functions/failed-precondition'); + }); + }); +}); diff --git a/test/integration/postcheck/esm/example.test.js b/test/integration/postcheck/esm/example.test.js index c731577f66..29d0654374 100644 --- a/test/integration/postcheck/esm/example.test.js +++ b/test/integration/postcheck/esm/example.test.js @@ -22,6 +22,7 @@ import { getAppCheck, AppCheck } from 'firebase-admin/app-check'; import { getAuth, Auth } from 'firebase-admin/auth'; import { getDatabase, getDatabaseWithUrl, ServerValue } from 'firebase-admin/database'; import { getFirestore, DocumentReference, Firestore, FieldValue } from 'firebase-admin/firestore'; +import { getFunctions } from 'firebase-admin/functions'; import { getInstanceId, InstanceId } from 'firebase-admin/instance-id'; import { getMachineLearning, MachineLearning } from 'firebase-admin/machine-learning'; import { getMessaging, Messaging } from 'firebase-admin/messaging'; @@ -110,6 +111,12 @@ describe('ESM entry points', () => { expect(ref).to.be.instanceOf(DocumentReference); }); + it('Should return a Functions client', () => { + const fn = getFunctions(app); + expect(fn).to.be.not.undefined; + expect(typeof fn.taskQueue).to.equal('function'); + }); + it('Should return an InstanceId client', () => { const client = getInstanceId(app); expect(client).to.be.instanceOf(InstanceId); diff --git a/test/integration/postcheck/typescript/example-modular.test.ts b/test/integration/postcheck/typescript/example-modular.test.ts index 0d67abade4..c5ccf3c8a2 100644 --- a/test/integration/postcheck/typescript/example-modular.test.ts +++ b/test/integration/postcheck/typescript/example-modular.test.ts @@ -22,6 +22,7 @@ import { getAppCheck, AppCheck } from 'firebase-admin/app-check'; import { getAuth, Auth } from 'firebase-admin/auth'; import { getDatabase, getDatabaseWithUrl, Database, ServerValue } from 'firebase-admin/database'; import { getFirestore, DocumentReference, Firestore, FieldValue } from 'firebase-admin/firestore'; +import { getFunctions, Functions } from 'firebase-admin/functions'; import { getInstanceId, InstanceId } from 'firebase-admin/instance-id'; import { getMachineLearning, MachineLearning } from 'firebase-admin/machine-learning'; import { getMessaging, Messaging } from 'firebase-admin/messaging'; @@ -116,6 +117,12 @@ describe('Modular API', () => { expect(ref).to.be.instanceOf(DocumentReference); }); + it('Should return a Functions client', () => { + const fn: Functions = getFunctions(app); + expect(fn).to.be.not.undefined; + expect(typeof fn.taskQueue).to.equal('function'); + }); + it('Should return an InstanceId client', () => { const client = getInstanceId(app); expect(client).to.be.instanceOf(InstanceId); diff --git a/test/unit/functions/functions-api-client-internal.spec.ts b/test/unit/functions/functions-api-client-internal.spec.ts new file mode 100644 index 0000000000..8f5cc1b6f7 --- /dev/null +++ b/test/unit/functions/functions-api-client-internal.spec.ts @@ -0,0 +1,447 @@ +/*! + * @license + * Copyright 2022 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. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; +import { getSdkVersion } from '../../../src/utils'; + +import { FirebaseApp } from '../../../src/app/firebase-app'; +import { FirebaseFunctionsError, FunctionsApiClient } from '../../../src/functions/functions-api-client-internal'; +import { HttpClient } from '../../../src/utils/api-request'; +import { FirebaseAppError } from '../../../src/utils/error'; +import { deepCopy } from '../../../src/utils/deep-copy'; + +const expect = chai.expect; + +describe('FunctionsApiClient', () => { + + const ERROR_RESPONSE = { + error: { + code: 404, + message: 'Requested entity not found', + status: 'NOT_FOUND', + }, + }; + + const EXPECTED_HEADERS = { + 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, + 'Authorization': 'Bearer mock-token' + }; + + 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.'; + + const DEFAULT_REGION = 'us-central1'; + const CUSTOM_REGION = 'us-west1'; + const FUNCTION_NAME = 'function-name'; + const CUSTOM_PROJECT_ID = 'taskq-project'; + const EXTENSION_ID = 'image-resize'; + const PARTIAL_RESOURCE_NAME = `locations/${CUSTOM_REGION}/functions/${FUNCTION_NAME}`; + const FULL_RESOURCE_NAME = `projects/${CUSTOM_PROJECT_ID}/locations/${CUSTOM_REGION}/functions/${FUNCTION_NAME}`; + + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + serviceAccountId: 'service-acct@email.com' + }; + + const TEST_TASK_PAYLOAD = { + httpRequest: { + url: `https://${DEFAULT_REGION}-${mockOptions.projectId}.cloudfunctions.net/${FUNCTION_NAME}`, + oidcToken: { + serviceAccountEmail: mockOptions.serviceAccountId, + }, + body: Buffer.from(JSON.stringify({ data: {} })).toString('base64'), + headers: { 'Content-Type' : 'application/json' } + } + } + + const CLOUD_TASKS_URL = `https://cloudtasks.googleapis.com/v2/projects/${mockOptions.projectId}/locations/${DEFAULT_REGION}/queues/${FUNCTION_NAME}/tasks`; + + const CLOUD_TASKS_URL_EXT = `https://cloudtasks.googleapis.com/v2/projects/${mockOptions.projectId}/locations/${DEFAULT_REGION}/queues/ext-${EXTENSION_ID}-${FUNCTION_NAME}/tasks`; + + const CLOUD_TASKS_URL_FULL_RESOURCE = `https://cloudtasks.googleapis.com/v2/projects/${CUSTOM_PROJECT_ID}/locations/${CUSTOM_REGION}/queues/${FUNCTION_NAME}/tasks`; + + const CLOUD_TASKS_URL_PARTIAL_RESOURCE = `https://cloudtasks.googleapis.com/v2/projects/${mockOptions.projectId}/locations/${CUSTOM_REGION}/queues/${FUNCTION_NAME}/tasks`; + + const clientWithoutProjectId = new FunctionsApiClient(mocks.mockCredentialApp()); + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + let app: FirebaseApp; + let apiClient: FunctionsApiClient; + + beforeEach(() => { + app = mocks.appWithOptions(mockOptions); + apiClient = new FunctionsApiClient(app); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + return app.delete(); + }); + + describe('Constructor', () => { + it('should reject when the app is null', () => { + expect(() => new FunctionsApiClient(null as unknown as FirebaseApp)) + .to.throw('First argument passed to getFunctions() must be a valid Firebase app instance.'); + }); + }); + + describe('enqueue', () => { + let clock: sinon.SinonFakeTimers | undefined; + + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + }); + + it('should reject when project id is not available', () => { + return clientWithoutProjectId.enqueue({}, FUNCTION_NAME) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should reject when project id is not available in partial resource name', () => { + return clientWithoutProjectId.enqueue({}, PARTIAL_RESOURCE_NAME) + .should.eventually.be.rejectedWith(noProjectId); + }); + + for (const invalidName of [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop, undefined]) { + it(`should throw if functionName is ${invalidName}`, () => { + expect(() => apiClient.enqueue({}, invalidName as any)) + .to.throw('Function name must be a non empty string'); + }); + } + + for (const invalidName of ['project/abc/locations/east/fname', 'location/west/', '//']) { + it(`should throw if functionName is ${invalidName}`, () => { + expect(() => apiClient.enqueue({}, invalidName as any)) + .to.throw('Function name must be a single string or a qualified resource name'); + }); + } + + for (const invalidOption of [null, 'abc', '', [], true, 102, 1.2]) { + it(`should throw if options is ${invalidOption}`, () => { + expect(() => apiClient.enqueue({}, FUNCTION_NAME, '', invalidOption as any)) + .to.throw('TaskOptions must be a non-null object'); + }); + } + + for (const invalidScheduleTime of [null, '', 'abc', 102, 1.2, [], {}, true, NaN]) { + it(`should throw if scheduleTime is ${invalidScheduleTime}`, () => { + expect(() => apiClient.enqueue({}, FUNCTION_NAME, '', { scheduleTime: invalidScheduleTime } as any)) + .to.throw('scheduleTime must be a valid Date object.'); + }); + } + + for (const invalidScheduleDelaySeconds of [null, 'abc', '', [], {}, true, NaN, -1]) { + it(`should throw if scheduleDelaySeconds is ${invalidScheduleDelaySeconds}`, () => { + expect(() => apiClient.enqueue({}, FUNCTION_NAME, '', + { scheduleDelaySeconds: invalidScheduleDelaySeconds } as any)) + .to.throw('scheduleDelaySeconds must be a non-negative duration in seconds.'); + }); + } + + for (const invalidDispatchDeadlineSeconds of [null, 'abc', '', [], {}, true, NaN, -1, 14, 1801]) { + it(`should throw if dispatchDeadlineSeconds is ${invalidDispatchDeadlineSeconds}`, () => { + expect(() => apiClient.enqueue({}, FUNCTION_NAME, '', + { dispatchDeadlineSeconds: invalidDispatchDeadlineSeconds } as any)) + .to.throw('dispatchDeadlineSeconds must be a non-negative duration in seconds ' + + 'and must be in the range of 15s to 30 mins.'); + }); + } + + for (const invalidUri of [null, '', 'a', 'foo', 'image.jpg', [], {}, true, NaN]) { + it(`should throw given an invalid uri: ${invalidUri}`, () => { + expect(() => apiClient.enqueue({}, FUNCTION_NAME, '', + { uri: invalidUri } as any)) + .to.throw('uri must be a valid URL string.'); + }); + } + + it('should throw when both scheduleTime and scheduleDelaySeconds are provided', () => { + expect(() => apiClient.enqueue({}, FUNCTION_NAME, '', { + scheduleTime: new Date(), + scheduleDelaySeconds: 1000 + } as any)) + .to.throw('Both scheduleTime and scheduleDelaySeconds are provided. Only one value should be set.'); + }); + + 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 FirebaseFunctionsError('not-found', 'Requested entity not found'); + return apiClient.enqueue({}, FUNCTION_NAME) + .should.eventually.be.rejected.and.deep.include(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 FirebaseFunctionsError('unknown-error', 'Unknown server error: {}'); + return apiClient.enqueue({}, FUNCTION_NAME) + .should.eventually.be.rejected.and.deep.include(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 FirebaseFunctionsError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.enqueue({}, FUNCTION_NAME) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should reject when rejected with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME) + .should.eventually.be.rejected.and.deep.include(expected); + }); + + it('should resolve on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL, + headers: EXPECTED_HEADERS, + data: { + task: TEST_TASK_PAYLOAD + } + }); + }); + }); + + it('should resolve the projectId and location from the full resource name', () => { + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + expectedPayload.httpRequest.url = + `https://${CUSTOM_REGION}-${CUSTOM_PROJECT_ID}.cloudfunctions.net/${FUNCTION_NAME}`; + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FULL_RESOURCE_NAME) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL_FULL_RESOURCE, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload + } + }); + }); + }); + + it('should resolve the location from the partial resource name', () => { + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + expectedPayload.httpRequest.url = + `https://${CUSTOM_REGION}-${mockOptions.projectId}.cloudfunctions.net/${FUNCTION_NAME}`; + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, PARTIAL_RESOURCE_NAME) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL_PARTIAL_RESOURCE, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload + } + }); + }); + }); + + it('should update the function name when the extension-id is provided', () => { + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + expectedPayload.httpRequest.url = + `https://${DEFAULT_REGION}-${mockOptions.projectId}.cloudfunctions.net/ext-${EXTENSION_ID}-${FUNCTION_NAME}`; + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, EXTENSION_ID) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL_EXT, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload + } + }); + }); + }); + + it('should use the default projectId following a request with a full resource name', () => { + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + expectedPayload.httpRequest.url = + `https://${CUSTOM_REGION}-${CUSTOM_PROJECT_ID}.cloudfunctions.net/${FUNCTION_NAME}`; + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + // pass the full resource name. SDK should not use the default values + return apiClient.enqueue({}, FULL_RESOURCE_NAME) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL_FULL_RESOURCE, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload + } + }); + + // passing just the function name. SDK should deffer to default values + return apiClient.enqueue({}, FUNCTION_NAME); + }) + .then(() => { + expect(stub).to.have.been.calledTwice.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL, + headers: EXPECTED_HEADERS, + data: { + task: TEST_TASK_PAYLOAD + } + }); + }); + }); + + + + // tests for Task Options + it('should convert scheduleTime to ISO string', () => { + const scheduleTime = new Date(); + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + // timestamps should be converted to ISO strings + (expectedPayload as any).scheduleTime = scheduleTime.toISOString(); + + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, '', { scheduleTime }) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload, + } + }); + }); + }); + + it('should set scheduleTime based on scheduleDelaySeconds', () => { + clock = sinon.useFakeTimers(1000); + + const scheduleDelaySeconds = 1800; + const scheduleTime = new Date(); // '1970-01-01T00:00:01.000Z' + scheduleTime.setSeconds(scheduleTime.getSeconds() + scheduleDelaySeconds); // '1970-01-01T00:30:01.000Z' + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + // timestamps should be converted to ISO strings + (expectedPayload as any).scheduleTime = scheduleTime.toISOString(); + + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, '', { scheduleDelaySeconds }) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload, + } + }); + }); + }); + + it('should convert dispatchDeadline to a duration with `s` prefix', () => { + const dispatchDeadlineSeconds = 1800; + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + (expectedPayload as any).dispatchDeadline = `${dispatchDeadlineSeconds}s`; + + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue({}, FUNCTION_NAME, '', { dispatchDeadlineSeconds }) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload, + } + }); + }); + }); + + it('should encode data in the payload', () => { + const data = { privateKey: '~/.ssh/id_rsa.pub' }; + const expectedPayload = deepCopy(TEST_TASK_PAYLOAD); + expectedPayload.httpRequest.body = Buffer.from(JSON.stringify({ data })).toString('base64'); + + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({}, 200)); + stubs.push(stub); + return apiClient.enqueue(data, FUNCTION_NAME) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'POST', + url: CLOUD_TASKS_URL, + headers: EXPECTED_HEADERS, + data: { + task: expectedPayload, + } + }); + }); + }); + }); +}); diff --git a/test/unit/functions/functions.spec.ts b/test/unit/functions/functions.spec.ts new file mode 100644 index 0000000000..36e6098d42 --- /dev/null +++ b/test/unit/functions/functions.spec.ts @@ -0,0 +1,187 @@ +/*! + * @license + * Copyright 2022 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. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as mocks from '../../resources/mocks'; + +import { FirebaseApp } from '../../../src/app/firebase-app'; +import { FunctionsApiClient, FirebaseFunctionsError } from '../../../src/functions/functions-api-client-internal'; +import { HttpClient } from '../../../src/utils/api-request'; +import { Functions, TaskQueue } from '../../../src/functions/functions'; + +const expect = chai.expect; + +describe('Functions', () => { + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + }; + + let functions: Functions; + + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + + before(() => { + mockApp = mocks.appWithOptions(mockOptions); + mockCredentialApp = mocks.mockCredentialApp(); + functions = new Functions(mockApp); + }); + + after(() => { + return mockApp.delete(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + describe('Constructor', () => { + for (const invalidApp of [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop, + undefined]) { + it('should throw given invalid app: ' + JSON.stringify(invalidApp), () => { + expect(() => { + const functionsAny: any = Functions; + return new functionsAny(invalidApp); + }).to.throw( + 'First argument passed to getFunctions() must be a valid Firebase app ' + + 'instance.'); + }); + } + + it('should reject when initialized without project ID', () => { + // Remove Project ID from the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + 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.'; + const functionsWithoutProjectId = new Functions(mockCredentialApp); + return functionsWithoutProjectId.taskQueue('task-name').enqueue({}) + .should.eventually.rejectedWith(noProjectId); + }); + + it('should reject when failed to contact the Metadata server for service account email', () => { + const functionsWithProjectId = new Functions(mockApp); + const stub = sinon.stub(HttpClient.prototype, 'send') + .rejects(new Error('network error.')); + stubs.push(stub); + const expected = 'Failed to determine service account. Initialize the ' + + 'SDK with service account credentials or set service account ID as an app option.'; + return functionsWithProjectId.taskQueue('task-name').enqueue({}) + .should.eventually.be.rejectedWith(expected); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return new Functions(mockApp); + }).not.to.throw(); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(functions.app).to.equal(mockApp); + }); + }); +}); + +describe('TaskQueue', () => { + const INTERNAL_ERROR = new FirebaseFunctionsError('internal-error', 'message'); + const FUNCTION_NAME = 'function-name'; + + let taskQueue: TaskQueue; + let mockClient: FunctionsApiClient; + + let mockApp: FirebaseApp; + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + + before(() => { + mockApp = mocks.app(); + mockClient = new FunctionsApiClient(mockApp); + taskQueue = new TaskQueue(FUNCTION_NAME, mockClient); + }); + + after(() => { + return mockApp.delete(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + describe('Constructor', () => { + for (const invalidClient of [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop, + undefined]) { + it('should throw given invalid client: ' + JSON.stringify(invalidClient), () => { + expect(() => { + const taskQueueAny: any = TaskQueue; + return new taskQueueAny(FUNCTION_NAME, invalidClient); + }).to.throw( + 'Must provide a valid FunctionsApiClient instance to create a new TaskQueue.'); + }); + } + + for (const invalidFunctionName of [null, NaN, 0, 1, true, false, '', [], [1, 'a'], {}, { a: 1 }, _.noop, + undefined]) { + it('should throw given invalid name: ' + JSON.stringify(invalidFunctionName), () => { + expect(() => { + const taskQueueAny: any = TaskQueue; + return new taskQueueAny(invalidFunctionName, mockClient); + }).to.throw('`functionName` must be a non-empty string.'); + }); + } + + it('should not throw given a valid name and client', () => { + expect(() => { + return new TaskQueue(FUNCTION_NAME, mockClient); + }).not.to.throw(); + }); + }); + + describe('enqueue', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(FunctionsApiClient.prototype, 'enqueue') + .rejects(INTERNAL_ERROR); + stubs.push(stub); + return taskQueue.enqueue({}) + .should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + + it('should propagate API errors with task options', () => { + const stub = sinon + .stub(FunctionsApiClient.prototype, 'enqueue') + .rejects(INTERNAL_ERROR); + stubs.push(stub); + return taskQueue.enqueue({}, { scheduleDelaySeconds: 3600 }) + .should.eventually.be.rejected.and.deep.equal(INTERNAL_ERROR); + }); + }); +}); diff --git a/test/unit/functions/index.spec.ts b/test/unit/functions/index.spec.ts new file mode 100644 index 0000000000..8bfcaa5dfe --- /dev/null +++ b/test/unit/functions/index.spec.ts @@ -0,0 +1,76 @@ +/*! + * @license + * Copyright 2021 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. + */ + +'use strict'; + +import * as chai from 'chai'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +import * as mocks from '../../resources/mocks'; +import { App } from '../../../src/app/index'; +import { getFunctions, Functions } from '../../../src/functions/index'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('Functions', () => { + let mockApp: App; + let mockCredentialApp: App; + + const noProjectIdError = '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.'; + + beforeEach(() => { + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + }); + + describe('getFunctions()', () => { + it('should throw when default app is not available', () => { + expect(() => { + return getFunctions(); + }).to.throw('The default Firebase app does not exist.'); + }); + + it('should reject given an invalid credential without project ID', () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const functions = getFunctions(mockCredentialApp); + const factorizedQueue = functions.taskQueue('task-name'); + return factorizedQueue.enqueue({}) + .should.eventually.rejectedWith(noProjectIdError); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return getFunctions(mockApp); + }).not.to.throw(); + }); + + it('should return the same instance for a given app instance', () => { + const fn1: Functions = getFunctions(mockApp); + const fn2: Functions = getFunctions(mockApp); + expect(fn1).to.equal(fn2); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index ca1f63adee..aa7b262111 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -107,3 +107,7 @@ import './app-check/token-verifier.spec.ts'; // Eventarc import './eventarc/eventarc.spec'; import './eventarc/eventarc-utils.spec'; +// Functions +import './functions/index.spec'; +import './functions/functions.spec'; +import './functions/functions-api-client-internal.spec'; diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index 7d007f2f78..7105726e67 100644 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -22,7 +22,7 @@ import * as sinon from 'sinon'; import * as mocks from '../../resources/mocks'; import { addReadonlyGetter, getExplicitProjectId, findProjectId, - toWebSafeBase64, formatString, generateUpdateMask, transformMillisecondsToSecondsString, + toWebSafeBase64, formatString, generateUpdateMask, transformMillisecondsToSecondsString, parseResourceName, } from '../../../src/utils/index'; import { isNonEmptyString } from '../../../src/utils/validator'; import { FirebaseApp } from '../../../src/app/firebase-app'; @@ -396,3 +396,27 @@ describe('transformMillisecondsToSecondsString()', () => { }); }); }); + +describe('parseResourceName()', () => { + + const FULL_RESOURCE_NAME = 'projects/abc/locations/us/functions/f1'; + const PARTIAL_RESOURCE_NAME = 'locations/us/functions/f1'; + const projectId = 'abc'; + const locationId = 'us'; + const resourceId = 'f1'; + + it('should return projectId, location, and resource when given a full resource name', () => { + expect(parseResourceName(FULL_RESOURCE_NAME, 'functions')) + .to.deep.equal({ projectId, locationId, resourceId }); + }); + + it('should return location and resource when given a partial resource name', () => { + expect(parseResourceName(PARTIAL_RESOURCE_NAME, 'functions')) + .to.deep.equal({ projectId: undefined, locationId, resourceId }); + }); + + it('should return the resource when given only the resource name', () => { + expect(parseResourceName('f1', 'functions')) + .to.deep.equal({ resourceId }); + }); +}); From bd2bd35d913b237bc7019e4ae701a15c6304e15e Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 28 Apr 2022 11:46:13 -0400 Subject: [PATCH 2/3] fix lint error --- src/functions/functions-api-client-internal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 8bcd7bfd2f..36b7bf99c2 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -105,7 +105,7 @@ export class FunctionsApiClient { }); } - private getUrl(resourceName: utils.ParsedResource, UrlFormat: string): Promise { + private getUrl(resourceName: utils.ParsedResource, urlFormat: string): Promise { let { locationId } = resourceName; const { projectId, resourceId } = resourceName; if (typeof locationId === 'undefined' || !validator.isNonEmptyString(locationId)) { @@ -127,7 +127,7 @@ export class FunctionsApiClient { // Formats a string of form 'project/{projectId}/{api}' and replaces // with corresponding arguments {projectId: '1234', api: 'resource'} // and returns output: 'project/1234/resource'. - return utils.formatString(UrlFormat, urlParams); + return utils.formatString(urlFormat, urlParams); }); } From dcba8f0153b1a0dd867f2d63f53effb53bc5efa6 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 28 Apr 2022 12:02:52 -0400 Subject: [PATCH 3/3] remove intgration tests for now --- test/integration/functions.spec.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/integration/functions.spec.ts b/test/integration/functions.spec.ts index 0385289dbb..9b1f5277f9 100644 --- a/test/integration/functions.spec.ts +++ b/test/integration/functions.spec.ts @@ -32,12 +32,4 @@ describe('getFunctions()', () => { expect(typeof factorizeQueue.enqueue).to.equal('function'); }); }); - - describe('enqueue()', () => { - it('should propagate API errors', () => { - // rejects with failed-precondition when the queue does not exist - return getFunctions().taskQueue('non-existing-queue').enqueue({}) - .should.eventually.be.rejected.and.have.property('code', 'functions/failed-precondition'); - }); - }); });