diff --git a/etc/firebase-admin.messaging.api.md b/etc/firebase-admin.messaging.api.md index 4b8e6c1ed3..84cb3e9b84 100644 --- a/etc/firebase-admin.messaging.api.md +++ b/etc/firebase-admin.messaging.api.md @@ -189,6 +189,8 @@ export type Message = TokenMessage | TopicMessage | ConditionMessage; // @public export class Messaging { get app(): App; + // @deprecated + enableLegacyHttpTransport(): void; send(message: Message, dryRun?: boolean): Promise; // @deprecated sendAll(messages: Message[], dryRun?: boolean): Promise; diff --git a/src/app-check/app-check-api-client-internal.ts b/src/app-check/app-check-api-client-internal.ts index 6e99bc2c64..6e896044f6 100644 --- a/src/app-check/app-check-api-client-internal.ts +++ b/src/app-check/app-check-api-client-internal.ts @@ -18,7 +18,7 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { - HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse + HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient, RequestResponse } from '../utils/api-request'; import { PrefixedFirebaseError } from '../utils/error'; import * as utils from '../utils/index'; @@ -157,7 +157,7 @@ export class AppCheckApiClient { }); } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } @@ -184,7 +184,7 @@ export class AppCheckApiClient { * @param resp - API response object. * @returns An AppCheckToken instance. */ - private toAppCheckToken(resp: HttpResponse): AppCheckToken { + private toAppCheckToken(resp: RequestResponse): AppCheckToken { const token = resp.data.token; // `ttl` is a string with the suffix "s" preceded by the number of seconds, // with nanoseconds expressed as fractional seconds. diff --git a/src/app-check/token-generator.ts b/src/app-check/token-generator.ts index 2cfe3ca14c..0ae7032d64 100644 --- a/src/app-check/token-generator.ts +++ b/src/app-check/token-generator.ts @@ -24,7 +24,7 @@ import { APP_CHECK_ERROR_CODE_MAPPING, } from './app-check-api-client-internal'; import { AppCheckTokenOptions } from './app-check-api'; -import { HttpError } from '../utils/api-request'; +import { RequestResponseError } from '../utils/api-request'; const ONE_MINUTE_IN_SECONDS = 60; const ONE_MINUTE_IN_MILLIS = ONE_MINUTE_IN_SECONDS * 1000; @@ -147,7 +147,7 @@ export function appCheckErrorFromCryptoSignerError(err: Error): Error { return err; } if (err.code === CryptoSignerErrorCode.SERVER_ERROR && validator.isNonNullObject(err.cause)) { - const httpError = err.cause as HttpError + const httpError = err.cause as RequestResponseError const errorResponse = httpError.response.data; if (errorResponse?.error) { const status = errorResponse.error.status; diff --git a/src/app/credential-internal.ts b/src/app/credential-internal.ts index f6a3d3bc78..94ac1ea844 100644 --- a/src/app/credential-internal.ts +++ b/src/app/credential-internal.ts @@ -22,7 +22,7 @@ import path = require('path'); import { Agent } from 'http'; import { Credential, GoogleOAuthAccessToken } from './credential'; import { AppErrorCodes, FirebaseAppError } from '../utils/error'; -import { HttpClient, HttpRequestConfig, HttpError, HttpResponse } from '../utils/api-request'; +import { HttpClient, HttpRequestConfig, RequestResponseError, RequestResponse } from '../utils/api-request'; import * as util from '../utils/validator'; const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; @@ -232,7 +232,8 @@ export class ComputeEngineCredential implements Credential { return this.projectId; }) .catch((err) => { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + const detail: string = + (err instanceof RequestResponseError) ? getDetailFromResponse(err.response) : err.message; throw new FirebaseAppError( AppErrorCodes.INVALID_CREDENTIAL, `Failed to determine project ID: ${detail}`); @@ -251,7 +252,8 @@ export class ComputeEngineCredential implements Credential { return this.accountId; }) .catch((err) => { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + const detail: string = + (err instanceof RequestResponseError) ? getDetailFromResponse(err.response) : err.message; throw new FirebaseAppError( AppErrorCodes.INVALID_CREDENTIAL, `Failed to determine service account email: ${detail}`); @@ -553,7 +555,7 @@ function requestIDToken(client: HttpClient, request: HttpRequestConfig): Promise * Constructs a human-readable error message from the given Error. */ function getErrorMessage(err: Error): string { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + const detail: string = (err instanceof RequestResponseError) ? getDetailFromResponse(err.response) : err.message; return `Error fetching access token: ${detail}`; } @@ -562,7 +564,7 @@ function getErrorMessage(err: Error): string { * the response is JSON-formatted, looks up the error and error_description fields sent by the * Google Auth servers. Otherwise returns the entire response payload as the error detail. */ -function getDetailFromResponse(response: HttpResponse): string { +function getDetailFromResponse(response: RequestResponse): string { if (response.isJson() && response.data.error) { const json = response.data; let detail = json.error; diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 9fd535777c..68975fdae3 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -22,7 +22,7 @@ import { FirebaseApp } from '../app/firebase-app'; import { deepCopy, deepExtend } from '../utils/deep-copy'; import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { - ApiSettings, AuthorizedHttpClient, HttpRequestConfig, HttpError, + ApiSettings, AuthorizedHttpClient, HttpRequestConfig, RequestResponseError, } from '../utils/api-request'; import * as utils from '../utils/index'; @@ -1933,7 +1933,7 @@ export abstract class AbstractAuthRequestHandler { return response.data; }) .catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { const error = err.response.data; const errorCode = AbstractAuthRequestHandler.getErrorCode(error); if (!errorCode) { diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 7d3bd8a7c6..ce6e21fbb6 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -16,7 +16,7 @@ */ import { AuthClientErrorCode, ErrorInfo, FirebaseAuthError } from '../utils/error'; -import { HttpError } from '../utils/api-request'; +import { RequestResponseError } from '../utils/api-request'; import { CryptoSigner, CryptoSignerError, CryptoSignerErrorCode } from '../utils/crypto-signer'; import * as validator from '../utils/validator'; @@ -213,7 +213,7 @@ export function handleCryptoSignerError(err: Error): Error { } if (err.code === CryptoSignerErrorCode.SERVER_ERROR && validator.isNonNullObject(err.cause)) { const httpError = err.cause; - const errorResponse = (httpError as HttpError).response.data; + const errorResponse = (httpError as RequestResponseError).response.data; if (validator.isNonNullObject(errorResponse) && errorResponse.error) { const errorCode = errorResponse.error.status; const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + diff --git a/src/database/database.ts b/src/database/database.ts index 555072f2df..144f08d430 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -24,7 +24,7 @@ import { Database as DatabaseImpl } from '@firebase/database-compat/standalone'; import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import * as validator from '../utils/validator'; -import { AuthorizedHttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { AuthorizedHttpClient, HttpRequestConfig, RequestResponseError } from '../utils/api-request'; import { getSdkVersion } from '../utils/index'; /** @@ -292,7 +292,7 @@ class DatabaseRulesClient { } private handleError(err: Error): Error { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { return new FirebaseDatabaseError({ code: AppErrorCodes.INTERNAL_ERROR, message: this.getErrorMessage(err), @@ -301,7 +301,7 @@ class DatabaseRulesClient { return err; } - private getErrorMessage(err: HttpError): string { + private getErrorMessage(err: RequestResponseError): string { const intro = 'Error while accessing security rules'; try { const body: { error?: string } = err.response.data; diff --git a/src/eventarc/eventarc-client-internal.ts b/src/eventarc/eventarc-client-internal.ts index ce02967c93..3aecbdcc96 100644 --- a/src/eventarc/eventarc-client-internal.ts +++ b/src/eventarc/eventarc-client-internal.ts @@ -20,7 +20,7 @@ import { FirebaseEventarcError, toCloudEventProtoFormat } from './eventarc-utils import { App } from '../app'; import { Channel } from './eventarc'; import { - HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient + HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient } from '../utils/api-request'; import { FirebaseApp } from '../app/firebase-app'; import * as utils from '../utils'; @@ -117,7 +117,7 @@ export class EventarcApiClient { }); } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } diff --git a/src/extensions/extensions-api-client-internal.ts b/src/extensions/extensions-api-client-internal.ts index 407dd5a82e..a51c001304 100644 --- a/src/extensions/extensions-api-client-internal.ts +++ b/src/extensions/extensions-api-client-internal.ts @@ -17,7 +17,7 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; -import { AuthorizedHttpClient, HttpClient, HttpError, HttpRequestConfig } from '../utils/api-request'; +import { AuthorizedHttpClient, HttpClient, RequestResponseError, HttpRequestConfig } from '../utils/api-request'; import { FirebaseAppError, PrefixedFirebaseError } from '../utils/error'; import * as validator from '../utils/validator'; import * as utils from '../utils'; @@ -76,7 +76,7 @@ export class ExtensionsApiClient { }/${EXTENSIONS_API_VERSION}/projects/${projectId}/instances/${instanceId}/runtimeData`; } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } diff --git a/src/functions/functions-api-client-internal.ts b/src/functions/functions-api-client-internal.ts index 85a447cf33..a98a599ed0 100644 --- a/src/functions/functions-api-client-internal.ts +++ b/src/functions/functions-api-client-internal.ts @@ -18,7 +18,7 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { - HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient + HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient } from '../utils/api-request'; import { PrefixedFirebaseError } from '../utils/error'; import * as utils from '../utils/index'; @@ -99,7 +99,7 @@ export class FunctionsApiClient { }; await this.httpClient.send(request); } catch (err: unknown) { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { if (err.response.status === 404) { // if no task with the provided ID exists, then ignore the delete. return; @@ -156,7 +156,7 @@ export class FunctionsApiClient { }; await this.httpClient.send(request); } catch (err: unknown) { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { if (err.response.status === 409) { throw new FirebaseFunctionsError('task-already-exists', `A task with ID ${opts?.id} already exists`); } else { @@ -321,7 +321,7 @@ export class FunctionsApiClient { return task; } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } diff --git a/src/installations/installations-request-handler.ts b/src/installations/installations-request-handler.ts index f6043c46d0..5314642d55 100644 --- a/src/installations/installations-request-handler.ts +++ b/src/installations/installations-request-handler.ts @@ -19,7 +19,7 @@ import { App } from '../app/index'; import { FirebaseApp } from '../app/firebase-app'; import { FirebaseInstallationsError, InstallationsClientErrorCode } from '../utils/error'; import { - ApiSettings, AuthorizedHttpClient, HttpRequestConfig, HttpError, + ApiSettings, AuthorizedHttpClient, HttpRequestConfig, RequestResponseError, } from '../utils/api-request'; import * as utils from '../utils/index'; @@ -93,7 +93,7 @@ export class FirebaseInstallationsRequestHandler { // return nothing on success }) .catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { const response = err.response; const errorMessage: string = (response.isJson() && 'error' in response.data) ? response.data.error : response.text; diff --git a/src/machine-learning/machine-learning-api-client.ts b/src/machine-learning/machine-learning-api-client.ts index 7ae1c3436a..c3ea7cee4f 100644 --- a/src/machine-learning/machine-learning-api-client.ts +++ b/src/machine-learning/machine-learning-api-client.ts @@ -17,7 +17,7 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { - HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, ExponentialBackoffPoller + HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient, ExponentialBackoffPoller } from '../utils/api-request'; import { PrefixedFirebaseError } from '../utils/error'; import * as utils from '../utils/index'; @@ -369,7 +369,7 @@ export class MachineLearningApiClient { }); } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } diff --git a/src/messaging/batch-request-internal.ts b/src/messaging/batch-request-internal.ts index d3f18c9942..7f87d7bde1 100644 --- a/src/messaging/batch-request-internal.ts +++ b/src/messaging/batch-request-internal.ts @@ -15,7 +15,7 @@ */ import { - HttpClient, HttpRequestConfig, HttpResponse, parseHttpResponse, + HttpClient, HttpRequestConfig, RequestResponse, parseHttpResponse, } from '../utils/api-request'; import { FirebaseAppError, AppErrorCodes } from '../utils/error'; @@ -54,12 +54,12 @@ export class BatchRequestClient { /** * Sends the given array of sub requests as a single batch, and parses the results into an array - * of HttpResponse objects. + * of `RequestResponse` objects. * * @param requests - An array of sub requests to send. * @returns A promise that resolves when the send operation is complete. */ - public send(requests: SubRequest[]): Promise { + public send(requests: SubRequest[]): Promise { requests = requests.map((req) => { req.headers = Object.assign({}, this.commonHeaders, req.headers); return req; diff --git a/src/messaging/messaging-api-request-internal.ts b/src/messaging/messaging-api-request-internal.ts index 90be03181f..db1aabcc52 100644 --- a/src/messaging/messaging-api-request-internal.ts +++ b/src/messaging/messaging-api-request-internal.ts @@ -18,7 +18,8 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { - HttpMethod, AuthorizedHttpClient, HttpRequestConfig, HttpError, HttpResponse, + HttpMethod, AuthorizedHttpClient, HttpRequestConfig, RequestResponseError, RequestResponse, + AuthorizedHttp2Client, Http2SessionHandler, Http2RequestConfig, } from '../utils/api-request'; import { createFirebaseError, getErrorCode } from './messaging-errors-internal'; import { SubRequest, BatchRequestClient } from './batch-request-internal'; @@ -44,6 +45,7 @@ const LEGACY_FIREBASE_MESSAGING_HEADERS = { */ export class FirebaseMessagingRequestHandler { private readonly httpClient: AuthorizedHttpClient; + private readonly http2Client: AuthorizedHttp2Client; private readonly batchClient: BatchRequestClient; /** @@ -52,6 +54,7 @@ export class FirebaseMessagingRequestHandler { */ constructor(app: App) { this.httpClient = new AuthorizedHttpClient(app as FirebaseApp); + this.http2Client = new AuthorizedHttp2Client(app as FirebaseApp); this.batchClient = new BatchRequestClient( this.httpClient, FIREBASE_MESSAGING_BATCH_URL, FIREBASE_MESSAGING_HEADERS); } @@ -75,20 +78,20 @@ export class FirebaseMessagingRequestHandler { return this.httpClient.send(request).then((response) => { // Send non-JSON responses to the catch() below where they will be treated as errors. if (!response.isJson()) { - throw new HttpError(response); + throw new RequestResponseError(response); } // Check for backend errors in the response. const errorCode = getErrorCode(response.data); if (errorCode) { - throw new HttpError(response); + throw new RequestResponseError(response); } // Return entire response. return response.data; }) .catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { throw createFirebaseError(err); } // Re-throw the error if it already has the proper format. @@ -97,14 +100,16 @@ export class FirebaseMessagingRequestHandler { } /** - * Invokes the request handler with the provided request data. + * Invokes the HTTP/1.1 request handler with the provided request data. * * @param host - The host to which to send the request. * @param path - The path to which to send the request. * @param requestData - The request data. * @returns A promise that resolves with the {@link SendResponse}. */ - public invokeRequestHandlerForSendResponse(host: string, path: string, requestData: object): Promise { + public invokeHttpRequestHandlerForSendResponse( + host: string, path: string, requestData: object + ): Promise { const request: HttpRequestConfig = { method: FIREBASE_MESSAGING_HTTP_METHOD, url: `https://${host}${path}`, @@ -116,7 +121,38 @@ export class FirebaseMessagingRequestHandler { return this.buildSendResponse(response); }) .catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { + return this.buildSendResponseFromError(err); + } + // Re-throw the error if it already has the proper format. + throw err; + }); + } + + /** + * Invokes the HTTP/2 request handler with the provided request data. + * + * @param host - The host to which to send the request. + * @param path - The path to which to send the request. + * @param requestData - The request data. + * @returns A promise that resolves with the {@link SendResponse}. + */ + public invokeHttp2RequestHandlerForSendResponse( + host: string, path: string, requestData: object, http2SessionHandler: Http2SessionHandler + ): Promise { + const request: Http2RequestConfig = { + method: FIREBASE_MESSAGING_HTTP_METHOD, + url: `https://${host}${path}`, + data: requestData, + headers: LEGACY_FIREBASE_MESSAGING_HEADERS, + timeout: FIREBASE_MESSAGING_TIMEOUT, + http2SessionHandler: http2SessionHandler + }; + return this.http2Client.send(request).then((response) => { + return this.buildSendResponse(response); + }) + .catch((err) => { + if (err instanceof RequestResponseError) { return this.buildSendResponseFromError(err); } // Re-throw the error if it already has the proper format. @@ -126,15 +162,15 @@ export class FirebaseMessagingRequestHandler { /** * Sends the given array of sub requests as a single batch to FCM, and parses the result into - * a BatchResponse object. + * a `BatchResponse` object. * * @param requests - An array of sub requests to send. * @returns A promise that resolves when the send operation is complete. */ public sendBatchRequest(requests: SubRequest[]): Promise { return this.batchClient.send(requests) - .then((responses: HttpResponse[]) => { - return responses.map((part: HttpResponse) => { + .then((responses: RequestResponse[]) => { + return responses.map((part: RequestResponse) => { return this.buildSendResponse(part); }); }).then((responses: SendResponse[]) => { @@ -145,7 +181,7 @@ export class FirebaseMessagingRequestHandler { failureCount: responses.length - successCount, }; }).catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { throw createFirebaseError(err); } // Re-throw the error if it already has the proper format. @@ -153,19 +189,19 @@ export class FirebaseMessagingRequestHandler { }); } - private buildSendResponse(response: HttpResponse): SendResponse { + private buildSendResponse(response: RequestResponse): SendResponse { const result: SendResponse = { success: response.status === 200, }; if (result.success) { result.messageId = response.data.name; } else { - result.error = createFirebaseError(new HttpError(response)); + result.error = createFirebaseError(new RequestResponseError(response)); } return result; } - private buildSendResponseFromError(err: HttpError): SendResponse { + private buildSendResponseFromError(err: RequestResponseError): SendResponse { return { success: false, error: createFirebaseError(err) diff --git a/src/messaging/messaging-errors-internal.ts b/src/messaging/messaging-errors-internal.ts index a9dd8794af..a04e4cfb2b 100644 --- a/src/messaging/messaging-errors-internal.ts +++ b/src/messaging/messaging-errors-internal.ts @@ -14,18 +14,18 @@ * limitations under the License. */ -import { HttpError } from '../utils/api-request'; +import { RequestResponseError } from '../utils/api-request'; import { FirebaseMessagingError, MessagingClientErrorCode } from '../utils/error'; import * as validator from '../utils/validator'; /** - * Creates a new FirebaseMessagingError by extracting the error code, message and other relevant - * details from an HTTP error response. + * Creates a new `FirebaseMessagingError` by extracting the error code, message and other relevant + * details from a `RequestResponseError` response. * - * @param err - The HttpError to convert into a Firebase error + * @param err - The `RequestResponseError` to convert into a Firebase error * @returns A Firebase error that can be returned to the user. */ -export function createFirebaseError(err: HttpError): FirebaseMessagingError { +export function createFirebaseError(err: RequestResponseError): FirebaseMessagingError { if (err.response.isJson()) { // For JSON responses, map the server response to a client-side error. const json = err.response.data; diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index f7a2a14ddf..ad78783fb9 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -41,6 +41,7 @@ import { NotificationMessagePayload, SendResponse, } from './messaging-api'; +import { Http2SessionHandler } from '../utils/api-request'; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; @@ -103,11 +104,11 @@ const MESSAGING_CONDITION_RESPONSE_KEYS_MAP = { }; /** - * Maps a raw FCM server response to a MessagingDevicesResponse object. + * Maps a raw FCM server response to a `MessagingDevicesResponse` object. * * @param response - The raw FCM server response to map. * - * @returns The mapped MessagingDevicesResponse object. + * @returns The mapped `MessagingDevicesResponse` object. */ function mapRawResponseToDevicesResponse(response: object): MessagingDevicesResponse { // Rename properties on the server response @@ -130,11 +131,11 @@ function mapRawResponseToDevicesResponse(response: object): MessagingDevicesResp } /** - * Maps a raw FCM server response to a MessagingDeviceGroupResponse object. + * Maps a raw FCM server response to a `MessagingDeviceGroupResponse` object. * * @param response - The raw FCM server response to map. * - * @returns The mapped MessagingDeviceGroupResponse object. + * @returns The mapped `MessagingDeviceGroupResponse` object. */ function mapRawResponseToDeviceGroupResponse(response: object): MessagingDeviceGroupResponse { // Rename properties on the server response @@ -148,11 +149,11 @@ function mapRawResponseToDeviceGroupResponse(response: object): MessagingDeviceG } /** - * Maps a raw FCM server response to a MessagingTopicManagementResponse object. + * Maps a raw FCM server response to a `MessagingTopicManagementResponse` object. * * @param {object} response The raw FCM server response to map. * - * @returns {MessagingTopicManagementResponse} The mapped MessagingTopicManagementResponse object. + * @returns {MessagingTopicManagementResponse} The mapped `MessagingTopicManagementResponse` object. */ function mapRawResponseToTopicManagementResponse(response: object): MessagingTopicManagementResponse { // Add the success and failure counts. @@ -192,6 +193,7 @@ export class Messaging { private urlPath: string; private readonly appInternal: App; private readonly messagingRequestHandler: FirebaseMessagingRequestHandler; + private useLegacyTransport = false; /** * @internal @@ -221,6 +223,23 @@ export class Messaging { return this.appInternal; } + /** + * Enables the use of legacy HTTP/1.1 transport for `sendEach()` and `sendEachForMulticast()`. + * + * @example + * ```javascript + * const messaging = getMessaging(app); + * messaging.enableLegacyTransport(); + * messaging.sendEach(messages); + * ``` + * + * @deprecated This will be removed when the HTTP/2 transport implementation reaches the same + * stability as the legacy HTTP/1.1 implementation. + */ + public enableLegacyHttpTransport(): void { + this.useLegacyTransport = true; + } + /** * Sends the given message via FCM. * @@ -292,6 +311,8 @@ export class Messaging { MessagingClientErrorCode.INVALID_ARGUMENT, 'dryRun must be a boolean'); } + const http2SessionHandler = this.useLegacyTransport ? undefined : new Http2SessionHandler(`https://${FCM_SEND_HOST}`) + return this.getUrlPath() .then((urlPath) => { const requests: Promise[] = copy.map(async (message) => { @@ -300,10 +321,16 @@ export class Messaging { if (dryRun) { request.validate_only = true; } - return this.messagingRequestHandler.invokeRequestHandlerForSendResponse(FCM_SEND_HOST, urlPath, request); + + if (http2SessionHandler){ + return this.messagingRequestHandler.invokeHttp2RequestHandlerForSendResponse( + FCM_SEND_HOST, urlPath, request, http2SessionHandler); + } + return this.messagingRequestHandler.invokeHttpRequestHandlerForSendResponse(FCM_SEND_HOST, urlPath, request); }); return Promise.allSettled(requests); - }).then((results) => { + }) + .then((results) => { const responses: SendResponse[] = []; results.forEach(result => { if (result.status === 'fulfilled') { @@ -318,6 +345,11 @@ export class Messaging { successCount, failureCount: responses.length - successCount, }; + }) + .finally(() => { + if (http2SessionHandler){ + http2SessionHandler.close() + } }); } diff --git a/src/project-management/project-management-api-request-internal.ts b/src/project-management/project-management-api-request-internal.ts index d7e597d50d..d4a38cb2bc 100644 --- a/src/project-management/project-management-api-request-internal.ts +++ b/src/project-management/project-management-api-request-internal.ts @@ -17,7 +17,7 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { - AuthorizedHttpClient, HttpError, HttpMethod, HttpRequestConfig, ExponentialBackoffPoller, + AuthorizedHttpClient, RequestResponseError, HttpMethod, HttpRequestConfig, ExponentialBackoffPoller, } from '../utils/api-request'; import { FirebaseProjectManagementError, ProjectManagementErrorCode } from '../utils/error'; import { getSdkVersion } from '../utils/index'; @@ -322,13 +322,13 @@ export class ProjectManagementRequestHandler { .then((response) => { // Send non-JSON responses to the catch() below, where they will be treated as errors. if (!response.isJson()) { - throw new HttpError(response); + throw new RequestResponseError(response); } return response.data; }) .catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { ProjectManagementRequestHandler.wrapAndRethrowHttpError( err.response.status, err.response.text); } diff --git a/src/remote-config/remote-config-api-client-internal.ts b/src/remote-config/remote-config-api-client-internal.ts index f1a0ad1c10..30a0b10a54 100644 --- a/src/remote-config/remote-config-api-client-internal.ts +++ b/src/remote-config/remote-config-api-client-internal.ts @@ -16,7 +16,9 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; -import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse } from '../utils/api-request'; +import { + HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient,RequestResponse +} from '../utils/api-request'; import { PrefixedFirebaseError } from '../utils/error'; import * as utils from '../utils/index'; import * as validator from '../utils/validator'; @@ -193,7 +195,11 @@ export class RemoteConfigApiClient { }); } - private sendPutRequest(template: RemoteConfigTemplate, etag: string, validateOnly?: boolean): Promise { + private sendPutRequest( + template: RemoteConfigTemplate, + etag: string, + validateOnly?: boolean + ): Promise { let path = 'remoteConfig'; if (validateOnly) { path += '?validate_only=true'; @@ -242,7 +248,7 @@ export class RemoteConfigApiClient { }); } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } @@ -270,7 +276,7 @@ export class RemoteConfigApiClient { * @param {HttpResponse} resp API response object. * @param {string} customEtag A custom etag to replace the etag fom the API response (Optional). */ - private toRemoteConfigTemplate(resp: HttpResponse, customEtag?: string): RemoteConfigTemplate { + private toRemoteConfigTemplate(resp: RequestResponse, customEtag?: string): RemoteConfigTemplate { const etag = (typeof customEtag === 'undefined') ? resp.headers['etag'] : customEtag; this.validateEtag(etag); return { @@ -289,7 +295,7 @@ export class RemoteConfigApiClient { * @param {HttpResponse} resp API response object. * @param {string} customEtag A custom etag to replace the etag fom the API response (Optional). */ - private toRemoteConfigServerTemplate(resp: HttpResponse, customEtag?: string): ServerTemplateData { + private toRemoteConfigServerTemplate(resp: RequestResponse, customEtag?: string): ServerTemplateData { const etag = (typeof customEtag === 'undefined') ? resp.headers['etag'] : customEtag; this.validateEtag(etag); return { diff --git a/src/security-rules/security-rules-api-client-internal.ts b/src/security-rules/security-rules-api-client-internal.ts index 6d089b4475..7f10a27b93 100644 --- a/src/security-rules/security-rules-api-client-internal.ts +++ b/src/security-rules/security-rules-api-client-internal.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request'; +import { HttpRequestConfig, HttpClient, RequestResponseError, AuthorizedHttpClient } from '../utils/api-request'; import { PrefixedFirebaseError } from '../utils/error'; import { FirebaseSecurityRulesError, SecurityRulesErrorCode } from './security-rules-internal'; import * as utils from '../utils/index'; @@ -283,7 +283,7 @@ export class SecurityRulesApiClient { }); } - private toFirebaseError(err: HttpError): PrefixedFirebaseError { + private toFirebaseError(err: RequestResponseError): PrefixedFirebaseError { if (err instanceof PrefixedFirebaseError) { return err; } diff --git a/src/utils/api-request.ts b/src/utils/api-request.ts index 5d1b9ecf4c..69406c27ea 100644 --- a/src/utils/api-request.ts +++ b/src/utils/api-request.ts @@ -21,6 +21,7 @@ import * as validator from './validator'; import http = require('http'); import https = require('https'); +import http2 = require('http2'); import url = require('url'); import { EventEmitter } from 'events'; import { Readable } from 'stream'; @@ -32,9 +33,9 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; export type ApiCallbackFunction = (data: object) => void; /** - * Configuration for constructing a new HTTP request. + * Base configuration for constructing a new request. */ -export interface HttpRequestConfig { +export interface BaseRequestConfig { method: HttpMethod; /** Target URL of the request. Should be a well-formed URL including protocol, hostname, port and path. */ url: string; @@ -42,13 +43,28 @@ export interface HttpRequestConfig { data?: string | object | Buffer | null; /** Connect and read timeout (in milliseconds) for the outgoing request. */ timeout?: number; +} + +/** + * Configuration for constructing a new HTTP request. + */ +export interface HttpRequestConfig extends BaseRequestConfig { httpAgent?: http.Agent; } /** - * Represents an HTTP response received from a remote server. + * Configuration for constructing a new HTTP/2 request. + */ +export interface Http2RequestConfig extends BaseRequestConfig { + http2SessionHandler: Http2SessionHandler; +} + +type RequestConfig = HttpRequestConfig | Http2RequestConfig + +/** + * Represents an HTTP or HTTP/2 response received from a remote server. */ -export interface HttpResponse { +export interface RequestResponse { readonly status: number; readonly headers: any; /** Response data as a raw string. */ @@ -64,23 +80,47 @@ export interface HttpResponse { isJson(): boolean; } -interface LowLevelResponse { +interface BaseLowLevelResponse { status: number; - headers: http.IncomingHttpHeaders; - request: http.ClientRequest | null; data?: string; multipart?: Buffer[]; - config: HttpRequestConfig; } -interface LowLevelError extends Error { +interface LowLevelHttpResponse extends BaseLowLevelResponse { + headers: http.IncomingHttpHeaders; + request: http.ClientRequest | null; config: HttpRequestConfig; +} + +type IncomingHttp2Headers = http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader + +interface LowLevelHttp2Response extends BaseLowLevelResponse { + headers: IncomingHttp2Headers + request: http2.ClientHttp2Stream | null; + config: Http2RequestConfig; +} + +type LowLevelResponse = LowLevelHttpResponse | LowLevelHttp2Response + +interface BaseLowLevelError extends Error { code?: string; +} + +interface LowLevelHttpError extends BaseLowLevelError { + config: HttpRequestConfig; request?: http.ClientRequest; - response?: LowLevelResponse; + response?: LowLevelHttpResponse; +} + +interface LowLevelHttp2Error extends BaseLowLevelError { + config: Http2RequestConfig; + request?: http2.ClientHttp2Stream; + response?: LowLevelHttp2Response; } -class DefaultHttpResponse implements HttpResponse { +type LowLevelError = LowLevelHttpError | LowLevelHttp2Error + +class DefaultRequestResponse implements RequestResponse { public readonly status: number; public readonly headers: any; @@ -91,7 +131,7 @@ class DefaultHttpResponse implements HttpResponse { private readonly request: string; /** - * Constructs a new HttpResponse from the given LowLevelResponse. + * Constructs a new `RequestResponse` from the given `LowLevelResponse`. */ constructor(resp: LowLevelResponse) { this.status = resp.status; @@ -127,10 +167,10 @@ class DefaultHttpResponse implements HttpResponse { } /** - * Represents a multipart HTTP response. Parts that constitute the response body can be accessed + * Represents a multipart HTTP or HTTP/2 response. Parts that constitute the response body can be accessed * via the multipart getter. Getters for text and data throw errors. */ -class MultipartHttpResponse implements HttpResponse { +class MultipartRequestResponse implements RequestResponse { public readonly status: number; public readonly headers: any; @@ -161,23 +201,23 @@ class MultipartHttpResponse implements HttpResponse { } } -export class HttpError extends Error { - constructor(public readonly response: HttpResponse) { +export class RequestResponseError extends Error { + constructor(public readonly response: RequestResponse) { super(`Server responded with status ${response.status}.`); // Set the prototype so that instanceof checks will work correctly. // See: https://github.com/Microsoft/TypeScript/issues/13965 - Object.setPrototypeOf(this, HttpError.prototype); + Object.setPrototypeOf(this, RequestResponseError.prototype); } } /** - * Specifies how failing HTTP requests should be retried. + * Specifies how failing HTTP and HTTP/2 requests should be retried. */ export interface RetryConfig { /** Maximum number of times to retry a given request. */ maxRetries: number; - /** HTTP status codes that should be retried. */ + /** Response status codes that should be retried. */ statusCodes?: number[]; /** Low-level I/O error codes that should be retried. */ @@ -185,8 +225,8 @@ export interface RetryConfig { /** * The multiplier for exponential back off. The retry delay is calculated in seconds using the formula - * `(2^n) * backOffFactor`, where n is the number of retries performed so far. When the backOffFactor is set - * to 0, retries are not delayed. When the backOffFactor is 1, retry duration is doubled each iteration. + * `(2^n) * backOffFactor`, where n is the number of retries performed so far. When the `backOffFactor` is set + * to 0, retries are not delayed. When the `backOffFactor` is 1, retry duration is doubled each iteration. */ backOffFactor?: number; @@ -195,8 +235,8 @@ export interface RetryConfig { } /** - * Default retry configuration for HTTP requests. Retries up to 4 times on connection reset and timeout errors - * as well as HTTP 503 errors. Exposed as a function to ensure that every HttpClient gets its own RetryConfig + * Default retry configuration for HTTP and HTTP/2 requests. Retries up to 4 times on connection reset and timeout + * errors as well as 503 errors. Exposed as a function to ensure that every `RequestClient` gets its own `RetryConfig` * instance. */ export function defaultRetryConfig(): RetryConfig { @@ -210,7 +250,7 @@ export function defaultRetryConfig(): RetryConfig { } /** - * Ensures that the given RetryConfig object is valid. + * Ensures that the given `RetryConfig` object is valid. * * @param retry - The configuration to be validated. */ @@ -241,76 +281,24 @@ function validateRetryConfig(retry: RetryConfig): void { } } -export class HttpClient { +export class RequestClient { + protected readonly retry: RetryConfig; - constructor(private readonly retry: RetryConfig | null = defaultRetryConfig()) { - if (this.retry) { + constructor(retry: RetryConfig | null = defaultRetryConfig()) { + if (retry) { + this.retry = retry validateRetryConfig(this.retry); } } - /** - * Sends an HTTP request to a remote server. If the server responds with a successful response (2xx), the returned - * promise resolves with an HttpResponse. If the server responds with an error (3xx, 4xx, 5xx), the promise rejects - * with an HttpError. In case of all other errors, the promise rejects with a FirebaseAppError. If a request fails - * due to a low-level network error, transparently retries the request once before rejecting the promise. - * - * If the request data is specified as an object, it will be serialized into a JSON string. The application/json - * content-type header will also be automatically set in this case. For all other payload types, the content-type - * header should be explicitly set by the caller. To send a JSON leaf value (e.g. "foo", 5), parse it into JSON, - * and pass as a string or a Buffer along with the appropriate content-type header. - * - * @param config - HTTP request to be sent. - * @returns A promise that resolves with the response details. - */ - public send(config: HttpRequestConfig): Promise { - return this.sendWithRetry(config); - } - - /** - * Sends an HTTP request. In the event of an error, retries the HTTP request according to the - * RetryConfig set on the HttpClient. - * - * @param config - HTTP request to be sent. - * @param retryAttempts - Number of retries performed up to now. - * @returns A promise that resolves with the response details. - */ - private sendWithRetry(config: HttpRequestConfig, retryAttempts = 0): Promise { - return AsyncHttpCall.invoke(config) - .then((resp) => { - return this.createHttpResponse(resp); - }) - .catch((err: LowLevelError) => { - const [delayMillis, canRetry] = this.getRetryDelayMillis(retryAttempts, err); - if (canRetry && this.retry && delayMillis <= this.retry.maxDelayInMillis) { - return this.waitForRetry(delayMillis).then(() => { - return this.sendWithRetry(config, retryAttempts + 1); - }); - } - - if (err.response) { - throw new HttpError(this.createHttpResponse(err.response)); - } - - if (err.code === 'ETIMEDOUT') { - throw new FirebaseAppError( - AppErrorCodes.NETWORK_TIMEOUT, - `Error while making request: ${err.message}.`); - } - throw new FirebaseAppError( - AppErrorCodes.NETWORK_ERROR, - `Error while making request: ${err.message}. Error code: ${err.code}`); - }); - } - - private createHttpResponse(resp: LowLevelResponse): HttpResponse { + protected createRequestResponse(resp: LowLevelResponse): RequestResponse { if (resp.multipart) { - return new MultipartHttpResponse(resp); + return new MultipartRequestResponse(resp); } - return new DefaultHttpResponse(resp); + return new DefaultRequestResponse(resp); } - private waitForRetry(delayMillis: number): Promise { + protected waitForRetry(delayMillis: number): Promise { if (delayMillis > 0) { return new Promise((resolve) => { setTimeout(resolve, delayMillis); @@ -328,7 +316,7 @@ export class HttpClient { * @returns A 2-tuple where the 1st element is the duration to wait before another retry, and the * 2nd element is a boolean indicating whether the request is eligible for a retry or not. */ - private getRetryDelayMillis(retryAttempts: number, err: LowLevelError): [number, boolean] { + protected getRetryDelayMillis(retryAttempts: number, err: LowLevelError): [number, boolean] { if (!this.isRetryEligible(retryAttempts, err)) { return [0, false]; } @@ -344,7 +332,7 @@ export class HttpClient { return [this.backOffDelayMillis(retryAttempts), true]; } - private isRetryEligible(retryAttempts: number, err: LowLevelError): boolean { + protected isRetryEligible(retryAttempts: number, err: LowLevelError): boolean { if (!this.retry) { return false; } @@ -366,11 +354,11 @@ export class HttpClient { return false; } - /** - * Parses the Retry-After HTTP header as a milliseconds value. Return value is negative if the Retry-After header + /**??? + * Parses the Retry-After header as a milliseconds value. Return value is negative if the Retry-After header * contains an expired timestamp or otherwise malformed. */ - private parseRetryAfterIntoMillis(retryAfter: string): number { + protected parseRetryAfterIntoMillis(retryAfter: string): number { const delaySeconds: number = parseInt(retryAfter, 10); if (!isNaN(delaySeconds)) { return delaySeconds * 1000; @@ -383,7 +371,7 @@ export class HttpClient { return -1; } - private backOffDelayMillis(retryAttempts: number): number { + protected backOffDelayMillis(retryAttempts: number): number { if (retryAttempts === 0) { return 0; } @@ -398,15 +386,139 @@ export class HttpClient { } } +export class HttpClient extends RequestClient { + + constructor(retry?: RetryConfig | null) { + super(retry) + } + + /** + * Sends an HTTP request to a remote server. If the server responds with a successful response (2xx), the returned + * promise resolves with an `RequestResponse`. If the server responds with an error (3xx, 4xx, 5xx), the promise + * rejects with an `RequestResponseError`. In case of all other errors, the promise rejects with a `FirebaseAppError`. + * If a request fails due to a low-level network error, the client transparently retries the request once before + * rejecting the promise. + * + * If the request data is specified as an object, it will be serialized into a JSON string. The application/json + * content-type header will also be automatically set in this case. For all other payload types, the content-type + * header should be explicitly set by the caller. To send a JSON leaf value (e.g. "foo", 5), parse it into JSON, + * and pass as a string or a Buffer along with the appropriate content-type header. + * + * @param config - HTTP request to be sent. + * @returns A promise that resolves with the response details. + */ + public send(config: HttpRequestConfig): Promise { + return this.sendWithRetry(config); + } + + /** + * Sends an HTTP request. In the event of an error, retries the HTTP request according to the + * `RetryConfig` set on the `HttpClient`. + * + * @param config - HTTP request to be sent. + * @param retryAttempts - Number of retries performed up to now. + * @returns A promise that resolves with the response details. + */ + private sendWithRetry(config: HttpRequestConfig, retryAttempts = 0): Promise { + return AsyncHttpCall.invoke(config) + .then((resp) => { + return this.createRequestResponse(resp); + }) + .catch((err: LowLevelError) => { + const [delayMillis, canRetry] = this.getRetryDelayMillis(retryAttempts, err); + if (canRetry && this.retry && delayMillis <= this.retry.maxDelayInMillis) { + return this.waitForRetry(delayMillis).then(() => { + return this.sendWithRetry(config, retryAttempts + 1); + }); + } + + if (err.response) { + throw new RequestResponseError(this.createRequestResponse(err.response)); + } + + if (err.code === 'ETIMEDOUT') { + throw new FirebaseAppError( + AppErrorCodes.NETWORK_TIMEOUT, + `Error while making request: ${err.message}.`); + } + throw new FirebaseAppError( + AppErrorCodes.NETWORK_ERROR, + `Error while making request: ${err.message}. Error code: ${err.code}`); + }); + } +} + +export class Http2Client extends RequestClient { + + constructor(retry: RetryConfig | null = defaultRetryConfig()) { + super(retry); + } + + /** + * Sends an HTTP/2 request to a remote server. If the server responds with a successful response (2xx), the returned + * promise resolves with an `RequestResponse`. If the server responds with an error (3xx, 4xx, 5xx), the promise + * rejects with an `RequestResponseError`. In case of all other errors, the promise rejects with a `FirebaseAppError`. + * If a request fails due to a low-level network error, the client transparently retries the request once before + * rejecting the promise. + * + * If the request data is specified as an object, it will be serialized into a JSON string. The application/json + * content-type header will also be automatically set in this case. For all other payload types, the content-type + * header should be explicitly set by the caller. To send a JSON leaf value (e.g. "foo", 5), parse it into JSON, + * and pass as a string or a Buffer along with the appropriate content-type header. + * + * @param config - HTTP/2 request to be sent. + * @returns A promise that resolves with the response details. + */ + public send(config: Http2RequestConfig): Promise { + return this.sendWithRetry(config); + } + + /** + * Sends an HTTP/2 request. In the event of an error, retries the HTTP/2 request according to the + * `RetryConfig` set on the `Http2Client`. + * + * @param config - HTTP/2 request to be sent. + * @param retryAttempts - Number of retries performed up to now. + * @returns A promise that resolves with the response details. + */ + private sendWithRetry(config: Http2RequestConfig, retryAttempts = 0): Promise { + return AsyncHttp2Call.invoke(config) + .then((resp) => { + return this.createRequestResponse(resp); + }) + .catch((err: LowLevelError) => { + const [delayMillis, canRetry] = this.getRetryDelayMillis(retryAttempts, err); + if (canRetry && this.retry && delayMillis <= this.retry.maxDelayInMillis) { + return this.waitForRetry(delayMillis).then(() => { + return this.sendWithRetry(config, retryAttempts + 1); + }); + } + + if (err.response) { + throw new RequestResponseError(this.createRequestResponse(err.response)); + } + + if (err.code === 'ETIMEDOUT') { + throw new FirebaseAppError( + AppErrorCodes.NETWORK_TIMEOUT, + `Error while making request: ${err.message}.`); + } + throw new FirebaseAppError( + AppErrorCodes.NETWORK_ERROR, + `Error while making request: ${err.message}. Error code: ${err.code}`); + }); + } +} + /** - * Parses a full HTTP response message containing both a header and a body. + * Parses a full HTTP or HTTP/2 response message containing both a header and a body. * - * @param response - The HTTP response to be parsed. - * @param config - The request configuration that resulted in the HTTP response. - * @returns An object containing the parsed HTTP status, headers and the body. + * @param response - The HTTP or HTTP/2 response to be parsed. + * @param config - The request configuration that resulted in the HTTP or HTTP/2 response. + * @returns An object containing the response's parsed status, headers and the body. */ export function parseHttpResponse( - response: string | Buffer, config: HttpRequestConfig): HttpResponse { + response: string | Buffer, config: RequestConfig): RequestResponse { const responseText: string = validator.isBuffer(response) ? response.toString('utf-8') : response as string; @@ -442,101 +554,21 @@ export function parseHttpResponse( if (!validator.isNumber(lowLevelResponse.status)) { throw new FirebaseAppError(AppErrorCodes.INTERNAL_ERROR, 'Malformed HTTP status line.'); } - return new DefaultHttpResponse(lowLevelResponse); + return new DefaultRequestResponse(lowLevelResponse); } /** - * A helper class for sending HTTP requests over the wire. This is a wrapper around the standard - * http and https packages of Node.js, providing content processing, timeouts and error handling. + * A helper class for common functionality needed to send requests over the wire. * It also wraps the callback API of the Node.js standard library in a more flexible Promise API. */ -class AsyncHttpCall { - - private readonly config: HttpRequestConfigImpl; - private readonly options: https.RequestOptions; - private readonly entity: Buffer | undefined; - private readonly promise: Promise; - - private resolve: (_: any) => void; - private reject: (_: any) => void; - - /** - * Sends an HTTP request based on the provided configuration. - */ - public static invoke(config: HttpRequestConfig): Promise { - return new AsyncHttpCall(config).promise; - } - - private constructor(config: HttpRequestConfig) { - try { - this.config = new HttpRequestConfigImpl(config); - this.options = this.config.buildRequestOptions(); - this.entity = this.config.buildEntity(this.options.headers!); - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - this.execute(); - }); - } catch (err) { - this.promise = Promise.reject(this.enhanceError(err, null)); - } - } - - private execute(): void { - const transport: any = this.options.protocol === 'https:' ? https : http; - const req: http.ClientRequest = transport.request(this.options, (res: http.IncomingMessage) => { - this.handleResponse(res, req); - }); - - // Handle errors - req.on('error', (err) => { - if (req.aborted) { - return; - } - this.enhanceAndReject(err, null, req); - }); - - const timeout: number | undefined = this.config.timeout; - const timeoutCallback: () => void = () => { - req.abort(); - this.rejectWithError(`timeout of ${timeout}ms exceeded`, 'ETIMEDOUT', req); - }; - if (timeout) { - // Listen to timeouts and throw an error. - req.setTimeout(timeout, timeoutCallback); - } - - // Send the request - req.end(this.entity); - } - - private handleResponse(res: http.IncomingMessage, req: http.ClientRequest): void { - if (req.aborted) { - return; - } +class AsyncRequestCall { + protected resolve: (_: any) => void; + protected reject: (_: any) => void; + protected options: https.RequestOptions; + protected entity: Buffer | undefined; + protected promise: Promise; - if (!res.statusCode) { - throw new FirebaseAppError( - AppErrorCodes.INTERNAL_ERROR, - 'Expected a statusCode on the response from a ClientRequest'); - } - - const response: LowLevelResponse = { - status: res.statusCode, - headers: res.headers, - request: req, - data: undefined, - config: this.config, - }; - const boundary = this.getMultipartBoundary(res.headers); - const respStream: Readable = this.uncompressResponse(res); - - if (boundary) { - this.handleMultipartResponse(response, respStream, boundary); - } else { - this.handleRegularResponse(response, respStream); - } - } + constructor(private readonly configImpl: HttpRequestConfigImpl | Http2RequestConfigImpl) {} /** * Extracts multipart boundary from the HTTP header. The content-type header of a multipart @@ -545,7 +577,7 @@ class AsyncHttpCall { * If the content-type header does not exist, or does not start with * 'multipart/', then null will be returned. */ - private getMultipartBoundary(headers: http.IncomingHttpHeaders): string | null { + protected getMultipartBoundary(headers: http.IncomingHttpHeaders): string | null { const contentType = headers['content-type']; if (!contentType || !contentType.startsWith('multipart/')) { return null; @@ -568,21 +600,7 @@ class AsyncHttpCall { return headerParams.boundary; } - private uncompressResponse(res: http.IncomingMessage): Readable { - // Uncompress the response body transparently if required. - let respStream: Readable = res; - const encodings = ['gzip', 'compress', 'deflate']; - if (res.headers['content-encoding'] && encodings.indexOf(res.headers['content-encoding']) !== -1) { - // Add the unzipper to the body stream processing pipeline. - const zlib: typeof zlibmod = require('zlib'); // eslint-disable-line @typescript-eslint/no-var-requires - respStream = respStream.pipe(zlib.createUnzip()); - // Remove the content-encoding in order to not confuse downstream operations. - delete res.headers['content-encoding']; - } - return respStream; - } - - private handleMultipartResponse( + protected handleMultipartResponse( response: LowLevelResponse, respStream: Readable, boundary: string): void { const busboy = require('@fastify/busboy'); // eslint-disable-line @typescript-eslint/no-var-requires @@ -609,15 +627,15 @@ class AsyncHttpCall { respStream.pipe(multipartParser); } - private handleRegularResponse(response: LowLevelResponse, respStream: Readable): void { + protected handleRegularResponse(response: LowLevelResponse, respStream: Readable): void { const responseBuffer: Buffer[] = []; respStream.on('data', (chunk: Buffer) => { responseBuffer.push(chunk); }); respStream.on('error', (err) => { - const req: http.ClientRequest | null = response.request; - if (req && req.aborted) { + const req = response.request; + if (req && req.destroyed) { return; } this.enhanceAndReject(err, null, req); @@ -630,10 +648,10 @@ class AsyncHttpCall { } /** - * Finalizes the current HTTP call in-flight by either resolving or rejecting the associated + * Finalizes the current request call in-flight by either resolving or rejecting the associated * promise. In the event of an error, adds additional useful information to the returned error. */ - private finalizeResponse(response: LowLevelResponse): void { + protected finalizeResponse(response: LowLevelResponse): void { if (response.status >= 200 && response.status < 300) { this.resolve(response); } else { @@ -648,38 +666,38 @@ class AsyncHttpCall { /** * Creates a new error from the given message, and enhances it with other information available. - * Then the promise associated with this HTTP call is rejected with the resulting error. + * Then the promise associated with this request call is rejected with the resulting error. */ - private rejectWithError( + protected rejectWithError( message: string, code?: string | null, - request?: http.ClientRequest | null, + request?: http.ClientRequest | http2.ClientHttp2Stream | null, response?: LowLevelResponse): void { const error = new Error(message); this.enhanceAndReject(error, code, request, response); } - private enhanceAndReject( + protected enhanceAndReject( error: any, code?: string | null, - request?: http.ClientRequest | null, + request?: http.ClientRequest | http2.ClientHttp2Stream | null, response?: LowLevelResponse): void { this.reject(this.enhanceError(error, code, request, response)); } /** - * Enhances the given error by adding more information to it. Specifically, the HttpRequestConfig, + * Enhances the given error by adding more information to it. Specifically, the request config, * the underlying request and response will be attached to the error. */ - private enhanceError( + protected enhanceError( error: any, code?: string | null, - request?: http.ClientRequest | null, + request?: http.ClientRequest | http2.ClientHttp2Stream | null, response?: LowLevelResponse): LowLevelError { - error.config = this.config; + error.config = this.configImpl; if (code) { error.code = code; } @@ -690,12 +708,220 @@ class AsyncHttpCall { } /** - * An adapter class for extracting options and entity data from an HttpRequestConfig. + * A helper class for sending HTTP requests over the wire. This is a wrapper around the standard + * http and https packages of Node.js, providing content processing, timeouts and error handling. + * It also wraps the callback API of the Node.js standard library in a more flexible Promise API. */ -class HttpRequestConfigImpl implements HttpRequestConfig { +class AsyncHttpCall extends AsyncRequestCall { + private readonly httpConfigImpl: HttpRequestConfigImpl; + + /** + * Sends an HTTP request based on the provided configuration. + */ + public static invoke(config: HttpRequestConfig): Promise { + return new AsyncHttpCall(config).promise; + } + + private constructor(config: HttpRequestConfig) { + const httpConfigImpl = new HttpRequestConfigImpl(config); + super(httpConfigImpl) + try { + this.httpConfigImpl = httpConfigImpl; + this.options = this.httpConfigImpl.buildRequestOptions(); + this.entity = this.httpConfigImpl.buildEntity(this.options.headers!); + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + this.execute(); + }); + } catch (err) { + this.promise = Promise.reject(this.enhanceError(err, null)); + } + } + + private execute(): void { + const transport: any = this.options.protocol === 'https:' ? https : http; + const req: http.ClientRequest = transport.request(this.options, (res: http.IncomingMessage) => { + this.handleResponse(res, req); + }); + + // Handle errors + req.on('error', (err) => { + if (req.aborted) { + return; + } + this.enhanceAndReject(err, null, req); + }); - constructor(private readonly config: HttpRequestConfig) { + const timeout: number | undefined = this.httpConfigImpl.timeout; + const timeoutCallback: () => void = () => { + req.destroy(); + this.rejectWithError(`timeout of ${timeout}ms exceeded`, 'ETIMEDOUT', req); + }; + if (timeout) { + // Listen to timeouts and throw an error. + req.setTimeout(timeout, timeoutCallback); + } + // Send the request + req.end(this.entity); + } + + private handleResponse(res: http.IncomingMessage, req: http.ClientRequest): void { + if (req.aborted) { + return; + } + + if (!res.statusCode) { + throw new FirebaseAppError( + AppErrorCodes.INTERNAL_ERROR, + 'Expected a statusCode on the response from a ClientRequest'); + } + + const response: LowLevelResponse = { + status: res.statusCode, + headers: res.headers, + request: req, + data: undefined, + config: this.httpConfigImpl, + }; + const boundary = this.getMultipartBoundary(res.headers); + const respStream: Readable = this.uncompressResponse(res); + + if (boundary) { + this.handleMultipartResponse(response, respStream, boundary); + } else { + this.handleRegularResponse(response, respStream); + } + } + + private uncompressResponse(res: http.IncomingMessage): Readable { + // Uncompress the response body transparently if required. + let respStream: Readable = res; + const encodings = ['gzip', 'compress', 'deflate']; + if (res.headers['content-encoding'] && encodings.indexOf(res.headers['content-encoding']) !== -1) { + // Add the unzipper to the body stream processing pipeline. + const zlib: typeof zlibmod = require('zlib'); // eslint-disable-line @typescript-eslint/no-var-requires + respStream = respStream.pipe(zlib.createUnzip()); + // Remove the content-encoding in order to not confuse downstream operations. + delete res.headers['content-encoding']; + } + return respStream; + } +} + +class AsyncHttp2Call extends AsyncRequestCall { + private readonly http2ConfigImpl: Http2RequestConfigImpl + + /** + * Sends an HTTP2 request based on the provided configuration. + */ + public static invoke(config: Http2RequestConfig): Promise { + return new AsyncHttp2Call(config).promise; + } + + private constructor(config: Http2RequestConfig) { + const http2ConfigImpl = new Http2RequestConfigImpl(config); + super(http2ConfigImpl) + try { + this.http2ConfigImpl = http2ConfigImpl; + this.options = this.http2ConfigImpl.buildRequestOptions(); + this.entity = this.http2ConfigImpl.buildEntity(this.options.headers!); + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + this.execute(); + }); + } catch (err) { + this.promise = Promise.reject(this.enhanceError(err, null)); + } + } + + private execute(): void { + const req = this.http2ConfigImpl.http2SessionHandler.session.request({ + ':method': this.options.method, + ':scheme': this.options.protocol!, + ':path': this.options.path!, + ...this.options.headers + }); + + req.on('response', (headers: IncomingHttp2Headers) => { + this.handleHttp2Response(headers, req); + }); + + // Handle errors + req.on('error', (err: any) => { + if (req.aborted) { + return; + } + this.enhanceAndReject(err, null, req); + }); + + const timeout: number | undefined = this.http2ConfigImpl.timeout; + const timeoutCallback: () => void = () => { + req.destroy(); + this.rejectWithError(`timeout of ${timeout}ms exceeded`, 'ETIMEDOUT', req); + }; + + if (timeout) { + // Listen to timeouts and throw an error. + req.setTimeout(timeout, timeoutCallback); + } + + req.end(this.entity); + } + + private handleHttp2Response(headers: IncomingHttp2Headers, stream: http2.ClientHttp2Stream): void{ + if (stream.aborted) { + return; + } + + if (!headers[':status']) { + throw new FirebaseAppError( + AppErrorCodes.INTERNAL_ERROR, + 'Expected a statusCode on the response from a ClientRequest'); + } + + const response: LowLevelHttp2Response = { + status: headers[':status'], + headers: headers, + request: stream, + data: undefined, + config: this.http2ConfigImpl, + }; + + const boundary = this.getMultipartBoundary(headers); + const respStream: Readable = this.uncompressResponse(headers, stream); + + if (boundary) { + this.handleMultipartResponse(response, respStream, boundary); + } else { + this.handleRegularResponse(response, respStream); + } + } + + private uncompressResponse(headers: IncomingHttp2Headers, stream: http2.ClientHttp2Stream): Readable { + // Uncompress the response body transparently if required. + let respStream: Readable = stream; + const encodings = ['gzip', 'compress', 'deflate']; + if (headers['content-encoding'] && encodings.indexOf(headers['content-encoding']) !== -1) { + // Add the unzipper to the body stream processing pipeline. + const zlib: typeof zlibmod = require('zlib'); // eslint-disable-line @typescript-eslint/no-var-requires + respStream = respStream.pipe(zlib.createUnzip()); + // Remove the content-encoding in order to not confuse downstream operations. + delete headers['content-encoding']; + } + return respStream; + } +} + +/** + * An adapter class with common functionality needed to extract options and entity data from a `RequestConfig`. + */ +class BaseRequestConfigImpl implements BaseRequestConfig { + + constructor(protected readonly config: RequestConfig) { + this.config = config } get method(): HttpMethod { @@ -718,36 +944,11 @@ class HttpRequestConfigImpl implements HttpRequestConfig { return this.config.timeout; } - get httpAgent(): http.Agent | undefined { - return this.config.httpAgent; - } - - public buildRequestOptions(): https.RequestOptions { - const parsed = this.buildUrl(); - const protocol = parsed.protocol; - let port: string | null = parsed.port; - if (!port) { - const isHttps = protocol === 'https:'; - port = isHttps ? '443' : '80'; - } - - return { - protocol, - hostname: parsed.hostname, - port, - path: parsed.path, - method: this.method, - agent: this.httpAgent, - headers: Object.assign({}, this.headers), - }; - } - public buildEntity(headers: http.OutgoingHttpHeaders): Buffer | undefined { let data: Buffer | undefined; if (!this.hasEntity() || !this.isEntityEnclosingRequest()) { return data; } - if (validator.isBuffer(this.data)) { data = this.data as Buffer; } else if (validator.isObject(this.data)) { @@ -760,22 +961,19 @@ class HttpRequestConfigImpl implements HttpRequestConfig { } else { throw new Error('Request data must be a string, a Buffer or a json serializable object'); } - // Add Content-Length header if data exists. headers['Content-Length'] = data.length.toString(); return data; } - private buildUrl(): url.UrlWithStringQuery { + protected buildUrl(): url.UrlWithStringQuery { const fullUrl: string = this.urlWithProtocol(); if (!this.hasEntity() || this.isEntityEnclosingRequest()) { return url.parse(fullUrl); } - if (!validator.isObject(this.data)) { throw new Error(`${this.method} requests cannot have a body`); } - // Parse URL and append data to query string. const parsedUrl = new url.URL(fullUrl); const dataObj = this.data as {[key: string]: string}; @@ -784,11 +982,10 @@ class HttpRequestConfigImpl implements HttpRequestConfig { parsedUrl.searchParams.append(key, dataObj[key]); } } - return url.parse(parsedUrl.toString()); } - private urlWithProtocol(): string { + protected urlWithProtocol(): string { const fullUrl: string = this.url; if (fullUrl.startsWith('http://') || fullUrl.startsWith('https://')) { return fullUrl; @@ -796,22 +993,83 @@ class HttpRequestConfigImpl implements HttpRequestConfig { return `https://${fullUrl}`; } - private hasEntity(): boolean { + protected hasEntity(): boolean { return !!this.data; } - private isEntityEnclosingRequest(): boolean { + protected isEntityEnclosingRequest(): boolean { // GET and HEAD requests do not support entity (body) in request. return this.method !== 'GET' && this.method !== 'HEAD'; } } +/** + * An adapter class for extracting options and entity data from an `HttpRequestConfig`. + */ +class HttpRequestConfigImpl extends BaseRequestConfigImpl implements HttpRequestConfig { + + constructor(private readonly httpConfig: HttpRequestConfig) { + super(httpConfig) + } + + get httpAgent(): http.Agent | undefined { + return this.httpConfig.httpAgent; + } + + public buildRequestOptions(): https.RequestOptions { + const parsed = this.buildUrl(); + const protocol = parsed.protocol; + let port: string | null = parsed.port; + if (!port) { + const isHttps = protocol === 'https:'; + port = isHttps ? '443' : '80'; + } + + return { + protocol, + hostname: parsed.hostname, + port, + path: parsed.path, + method: this.method, + agent: this.httpAgent, + headers: Object.assign({}, this.headers), + }; + } +} + +/** + * An adapter class for extracting options and entity data from an `Http2RequestConfig`. + */ +class Http2RequestConfigImpl extends BaseRequestConfigImpl implements Http2RequestConfig { + + constructor(private readonly http2Config: Http2RequestConfig) { + super(http2Config) + } + + get http2SessionHandler(): Http2SessionHandler { + return this.http2Config.http2SessionHandler; + } + + public buildRequestOptions(): https.RequestOptions { + const parsed = this.buildUrl(); + const protocol = parsed.protocol; + + return { + protocol, + path: parsed.path, + method: this.method, + headers: Object.assign({}, this.headers), + }; + } +} + export class AuthorizedHttpClient extends HttpClient { + constructor(private readonly app: FirebaseApp) { super(); } - public send(request: HttpRequestConfig): Promise { + public send(request: HttpRequestConfig): Promise { return this.getToken().then((token) => { const requestCopy = Object.assign({}, request); requestCopy.headers = Object.assign({}, request.headers); @@ -833,9 +1091,30 @@ export class AuthorizedHttpClient extends HttpClient { protected getToken(): Promise { return this.app.INTERNAL.getToken() - .then((accessTokenObj) => { - return accessTokenObj.accessToken; - }); + .then((accessTokenObj) => accessTokenObj.accessToken); + } +} + +export class AuthorizedHttp2Client extends Http2Client { + + constructor(private readonly app: FirebaseApp) { + super(); + } + + public send(request: Http2RequestConfig): Promise { + return this.getToken().then((token) => { + const requestCopy = Object.assign({}, request); + requestCopy.headers = Object.assign({}, request.headers); + const authHeader = 'Authorization'; + requestCopy.headers[authHeader] = `Bearer ${token}`; + + return super.send(requestCopy); + }); + } + + protected getToken(): Promise { + return this.app.INTERNAL.getToken() + .then((accessTokenObj) => accessTokenObj.accessToken); } } @@ -843,7 +1122,7 @@ export class AuthorizedHttpClient extends HttpClient { * Class that defines all the settings for the backend API endpoint. * * @param endpoint - The Firebase Auth backend endpoint. - * @param httpMethod - The http method for that endpoint. + * @param httpMethod - The HTTP method for that endpoint. * @constructor */ export class ApiSettings { @@ -1013,3 +1292,51 @@ export class ExponentialBackoffPoller extends EventEmitter { } } } + +export class Http2SessionHandler { + + private http2Session: http2.ClientHttp2Session + + constructor(url: string){ + this.http2Session = this.createSession(url) + } + + public createSession(url: string): http2.ClientHttp2Session { + if (!this.http2Session || this.isClosed ) { + const opts: http2.SecureClientSessionOptions = { + // Set local max concurrent stream limit to respect backend limit + peerMaxConcurrentStreams: 100, + ALPNProtocols: ['h2'] + } + const http2Session = http2.connect(url, opts) + + http2Session.on('goaway', (errorCode, _, opaqueData) => { + throw new FirebaseAppError( + AppErrorCodes.NETWORK_ERROR, + `Error while making requests: GOAWAY - ${opaqueData.toString()}, Error code: ${errorCode}` + ); + }) + + http2Session.on('error', (error) => { + throw new FirebaseAppError( + AppErrorCodes.NETWORK_ERROR, + `Error while making requests: ${error}` + ); + }) + return http2Session + } + return this.http2Session + } + + get session(): http2.ClientHttp2Session { + return this.http2Session + } + + get isClosed(): boolean { + return this.http2Session.closed + } + + public close(): void { + this.http2Session.close() + } +} \ No newline at end of file diff --git a/src/utils/crypto-signer.ts b/src/utils/crypto-signer.ts index ec33a3a714..3123cee209 100644 --- a/src/utils/crypto-signer.ts +++ b/src/utils/crypto-signer.ts @@ -18,7 +18,7 @@ import { App } from '../app'; import { FirebaseApp } from '../app/firebase-app'; import { ServiceAccountCredential } from '../app/credential-internal'; -import { AuthorizedHttpClient, HttpRequestConfig, HttpClient, HttpError } from './api-request'; +import { AuthorizedHttpClient, HttpRequestConfig, HttpClient, RequestResponseError } from './api-request'; import { Algorithm } from 'jsonwebtoken'; import { ErrorInfo } from '../utils/error'; @@ -138,7 +138,7 @@ export class IAMSigner implements CryptoSigner { // Response from IAM is base64 encoded. Decode it into a buffer and return. return Buffer.from(response.data.signedBlob, 'base64'); }).catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { throw new CryptoSignerError({ code: CryptoSignerErrorCode.SERVER_ERROR, message: err.message, diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index acd1038d2f..5d8f88344f 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -17,7 +17,7 @@ import * as validator from './validator'; import * as jwt from 'jsonwebtoken'; import * as jwks from 'jwks-rsa'; -import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { HttpClient, HttpRequestConfig, RequestResponseError } from '../utils/api-request'; import { Agent } from 'http'; export const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; @@ -140,7 +140,7 @@ export class UrlKeyFetcher implements KeyFetcher { if (!resp.isJson() || resp.data.error) { // Treat all non-json messages and messages with an 'error' field as // error responses. - throw new HttpError(resp); + throw new RequestResponseError(resp); } // reset expire at from previous set of keys. this.publicKeysExpireAt = 0; @@ -158,7 +158,7 @@ export class UrlKeyFetcher implements KeyFetcher { this.publicKeys = resp.data; return resp.data; }).catch((err) => { - if (err instanceof HttpError) { + if (err instanceof RequestResponseError) { let errorMessage = 'Error fetching public keys for Google certs: '; const resp = err.response; if (resp.isJson() && resp.data.error) { diff --git a/test/integration/messaging.spec.ts b/test/integration/messaging.spec.ts index 0a17a2d750..d11b035861 100644 --- a/test/integration/messaging.spec.ts +++ b/test/integration/messaging.spec.ts @@ -17,6 +17,7 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import { Message, MulticastMessage, getMessaging } from '../../lib/messaging/index'; +import { legacyTransportApp } from './setup'; chai.should(); chai.use(chaiAsPromised); @@ -101,6 +102,11 @@ const options = { }; describe('admin.messaging', () => { + + before(() => { + getMessaging(legacyTransportApp).enableLegacyHttpTransport() + }) + it('send(message, dryRun) returns a message ID', () => { return getMessaging().send(message, true) .then((name) => { @@ -110,7 +116,7 @@ describe('admin.messaging', () => { it('sendEach()', () => { const messages: Message[] = [message, message, message]; - return getMessaging().sendEach(messages, true) + return getMessaging(legacyTransportApp).sendEach(messages, true) .then((response) => { expect(response.responses.length).to.equal(messages.length); expect(response.successCount).to.equal(messages.length); @@ -123,6 +129,37 @@ describe('admin.messaging', () => { }); it('sendEach(500)', () => { + const messages: Message[] = []; + for (let i = 0; i < 500; i++) { + messages.push({ topic: `foo-bar-${i % 10}` }); + } + return getMessaging(legacyTransportApp).sendEach(messages, true) + .then((response) => { + expect(response.responses.length).to.equal(messages.length); + expect(response.successCount).to.equal(messages.length); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp) => { + expect(resp.success).to.be.true; + expect(resp.messageId).matches(/^projects\/.*\/messages\/.*$/); + }); + }); + }); + + it('sendEach() using HTTP2', () => { + const messages: Message[] = [message, message, message]; + return getMessaging().sendEach(messages, true) + .then((response) => { + expect(response.responses.length).to.equal(messages.length); + expect(response.successCount).to.equal(messages.length); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp) => { + expect(resp.success).to.be.true; + expect(resp.messageId).matches(/^projects\/.*\/messages\/.*$/); + }); + }); + }); + + it('sendEach(500) using HTTP2', () => { const messages: Message[] = []; for (let i = 0; i < 500; i++) { messages.push({ topic: `foo-bar-${i % 10}` }); @@ -171,6 +208,25 @@ describe('admin.messaging', () => { }); it('sendEachForMulticast()', () => { + const multicastMessage: MulticastMessage = { + data: message.data, + android: message.android, + tokens: ['not-a-token', 'also-not-a-token'], + }; + return getMessaging(legacyTransportApp).sendEachForMulticast(multicastMessage, true) + .then((response) => { + expect(response.responses.length).to.equal(2); + expect(response.successCount).to.equal(0); + expect(response.failureCount).to.equal(2); + response.responses.forEach((resp) => { + expect(resp.success).to.be.false; + expect(resp.messageId).to.be.undefined; + expect(resp.error).to.have.property('code', 'messaging/invalid-argument'); + }); + }); + }); + + it('sendEachForMulticast() using HTTP2', () => { const multicastMessage: MulticastMessage = { data: message.data, android: message.android, diff --git a/test/integration/setup.ts b/test/integration/setup.ts index fa217120f2..8c700d4c41 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -31,6 +31,7 @@ export let projectId: string; export let apiKey: string; export let defaultApp: App; +export let legacyTransportApp: App; export let nullApp: App; export let nonNullApp: App; export let noServiceAccountApp: App; @@ -92,6 +93,13 @@ before(() => { storageBucket, }); + legacyTransportApp = initializeApp({ + ...getCredential(), + projectId, + databaseURL: databaseUrl, + storageBucket, + }, 'legacyTransport'); + nullApp = initializeApp({ ...getCredential(), projectId, @@ -127,6 +135,7 @@ before(() => { after(() => { return Promise.all([ deleteApp(defaultApp), + deleteApp(legacyTransportApp), deleteApp(nullApp), deleteApp(nonNullApp), deleteApp(noServiceAccountApp), diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index a528dd5497..626e9663cb 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -21,9 +21,11 @@ import path = require('path'); import events = require('events'); import stream = require('stream'); +import http2 = require('http2'); import * as _ from 'lodash'; import * as jwt from 'jsonwebtoken'; +import * as sinon from 'sinon'; import { AppOptions } from '../../src/firebase-namespace-api'; import { FirebaseApp } from '../../src/app/firebase-app'; @@ -310,6 +312,93 @@ export class MockStream extends stream.PassThrough { public abort: () => void = () => undefined; } +export interface MockHttp2Request { + headers: http2.OutgoingHttpHeaders, + data: any +} + +export interface MockHttp2Response { + headers: http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader, + data: Buffer, + delay?: number, + error?: any +} + +export class Http2Mocker { + private connectStub: sinon.SinonStub | null; + private originalConnect = http2.connect; + private timeouts: NodeJS.Timeout[] = []; + private mockResponses: MockHttp2Response[] = []; + public requests: MockHttp2Request[] = []; + + public http2Stub(mockResponses: MockHttp2Response[]): void { + this.mockResponses = mockResponses + this.connectStub = sinon.stub(http2, 'connect'); + this.connectStub.callsFake((_target: any, options: any) => { + const session = this.originalConnect('https://www.example.com', options); + session.request = this.createMockRequest() + return session; + }) + } + + private createMockRequest() { + return (requestHeaders: http2.OutgoingHttpHeaders) => { + // Create a mock ClientHttp2Stream to return + const mockStream = new stream.Readable({ + // eslint-disable-next-line @typescript-eslint/no-empty-function + read() {} + }) as http2.ClientHttp2Stream; + + mockStream.end = (data: any) => { + this.requests.push({ headers: requestHeaders, data: data }) + return mockStream + }; + + mockStream.setTimeout = (timeout, callback: () => void) => { + this.timeouts.push(setTimeout(callback, timeout)) + } + + const mockRes = this.mockResponses.shift(); + if (mockRes) { + this.timeouts.push(setTimeout(() => { + if (mockRes.error) { + mockStream.emit('error', mockRes.error) + } + else { + mockStream.emit('response', mockRes.headers); + mockStream.emit('data', mockRes.data); + mockStream.emit('end'); + } + }, mockRes.delay)) + } + else { + throw Error('A mock request response was expected but not found.') + } + return mockStream; + } + } + + public done(): void { + // Clear timeouts + this.timeouts.forEach((timeout) => { + clearTimeout(timeout) + }) + + // Remove stub + if (this.connectStub) { + this.connectStub.restore(); + this.connectStub = null; + } + + // Check if all mock requests responces were used + if (this.mockResponses.length > 0) { + throw Error('A extra mock request was provided but not used.') + } + + this.requests = [] + } +} + /** * MESSAGING */ diff --git a/test/unit/messaging/batch-requests.spec.ts b/test/unit/messaging/batch-requests.spec.ts index fdde0c37f6..4f3b09957d 100644 --- a/test/unit/messaging/batch-requests.spec.ts +++ b/test/unit/messaging/batch-requests.spec.ts @@ -23,7 +23,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as utils from '../utils'; -import { HttpClient, HttpResponse, HttpRequestConfig, HttpError } from '../../../src/utils/api-request'; +import { HttpClient, RequestResponse, HttpRequestConfig, RequestResponseError } from '../../../src/utils/api-request'; import { SubRequest, BatchRequestClient } from '../../../src/messaging/batch-request-internal'; chai.should(); @@ -47,7 +47,7 @@ function getParsedPartData(obj: object): string { + `${json}`; } -function createMultipartResponse(success: object[], failures: object[] = []): HttpResponse { +function createMultipartResponse(success: object[], failures: object[] = []): RequestResponse { const multipart: Buffer[] = []; success.forEach((part) => { let payload = ''; @@ -97,7 +97,7 @@ describe('BatchRequestClient', () => { ]; const batch = new BatchRequestClient(httpClient, batchUrl); - const responses: HttpResponse[] = await batch.send(requests); + const responses: RequestResponse[] = await batch.send(requests); expect(responses.length).to.equal(1); expect(responses[0].status).to.equal(200); @@ -116,7 +116,7 @@ describe('BatchRequestClient', () => { ]; const batch = new BatchRequestClient(httpClient, batchUrl); - const responses: HttpResponse[] = await batch.send(requests); + const responses: RequestResponse[] = await batch.send(requests); expect(responses.length).to.equal(3); responses.forEach((response) => { @@ -137,7 +137,7 @@ describe('BatchRequestClient', () => { ]; const batch = new BatchRequestClient(httpClient, batchUrl); - const responses: HttpResponse[] = await batch.send(requests); + const responses: RequestResponse[] = await batch.send(requests); expect(responses.length).to.equal(3); responses.forEach((response, idx) => { @@ -163,8 +163,8 @@ describe('BatchRequestClient', () => { await batch.send(requests); sinon.assert.fail('No error thrown for HTTP error'); } catch (err) { - expect(err).to.be.instanceOf(HttpError); - expect((err as HttpError).response.status).to.equal(500); + expect(err).to.be.instanceOf(RequestResponseError); + expect((err as RequestResponseError).response.status).to.equal(500); checkOutgoingRequest(stub, requests); } }); @@ -180,7 +180,7 @@ describe('BatchRequestClient', () => { const commonHeaders = { 'X-Custom-Header': 'value' }; const batch = new BatchRequestClient(httpClient, batchUrl, commonHeaders); - const responses: HttpResponse[] = await batch.send(requests); + const responses: RequestResponse[] = await batch.send(requests); expect(responses.length).to.equal(1); expect(stub).to.have.been.calledOnce; @@ -205,7 +205,7 @@ describe('BatchRequestClient', () => { ]; const batch = new BatchRequestClient(httpClient, batchUrl); - const responses: HttpResponse[] = await batch.send(requests); + const responses: RequestResponse[] = await batch.send(requests); expect(responses.length).to.equal(1); expect(stub).to.have.been.calledOnce; @@ -229,7 +229,7 @@ describe('BatchRequestClient', () => { const commonHeaders = { 'X-Custom-Header': 'value' }; const batch = new BatchRequestClient(httpClient, batchUrl, commonHeaders); - const responses: HttpResponse[] = await batch.send(requests); + const responses: RequestResponse[] = await batch.send(requests); expect(responses.length).to.equal(1); expect(stub).to.have.been.calledOnce; diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index c343ea319d..a56767c123 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -80,6 +80,15 @@ function mockSendRequest(messageId = 'projects/projec_id/messages/message_id'): }); } +function mockHttp2SendRequestResponse(messageId = 'projects/projec_id/messages/message_id'): mocks.MockHttp2Response { + return { + headers: { + ':status': 200, + }, + data: Buffer.from(JSON.stringify({ name: `${messageId}` })), + } as mocks.MockHttp2Response +} + function mockBatchRequest(ids: string[]): nock.Scope { return mockBatchRequestWithErrors(ids); } @@ -127,6 +136,31 @@ function mockSendError( '/v1/projects/project_id/messages:send', statusCode, errorFormat, responseOverride); } +function mockHttp2SendRequestError( + statusCode: number, + errorFormat: 'json' | 'text', + responseOverride?: any, +): mocks.MockHttp2Response { + + let response; + let contentType: string; + if (errorFormat === 'json') { + response = Buffer.from(JSON.stringify(responseOverride || mockServerErrorResponse.json)); + contentType = 'application/json; charset=UTF-8'; + } else { + response = responseOverride || mockServerErrorResponse.text; + contentType = 'text/html; charset=UTF-8'; + } + + return { + headers: { + ':status': statusCode, + 'content-type': contentType + }, + data: Buffer.from(response) + } as mocks.MockHttp2Response +} + function mockBatchError( statusCode: number, errorFormat: 'json' | 'text', @@ -308,7 +342,10 @@ class CustomArray extends Array { } describe('Messaging', () => { let mockApp: FirebaseApp; let messaging: Messaging; + let legacyMessaging: Messaging; let mockedRequests: nock.Scope[] = []; + let mockedHttp2Responses: mocks.MockHttp2Response[] = [] + const http2Mocker: mocks.Http2Mocker = new mocks.Http2Mocker(); let httpsRequestStub: sinon.SinonStub; let getTokenStub: sinon.SinonStub; let nullAccessTokenMessaging: Messaging; @@ -332,6 +369,8 @@ describe('Messaging', () => { mockApp = mocks.app(); getTokenStub = utils.stubGetAccessToken(mockAccessToken, mockApp); messaging = new Messaging(mockApp); + legacyMessaging = new Messaging(mockApp); + legacyMessaging.enableLegacyHttpTransport(); nullAccessTokenMessaging = new Messaging(mocks.appReturningNullAccessToken()); messagingService = messaging; nullAccessTokenMessagingService = nullAccessTokenMessaging; @@ -343,6 +382,8 @@ describe('Messaging', () => { if (httpsRequestStub && httpsRequestStub.restore) { httpsRequestStub.restore(); } + http2Mocker.done() + mockedHttp2Responses = []; getTokenStub.restore(); return mockApp.delete(); }); @@ -610,7 +651,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/1', ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - return messaging.sendEach([invalidMessage, validMessage]) + return legacyMessaging.sendEach([invalidMessage, validMessage]) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); expect(response.failureCount).to.equal(1); @@ -633,7 +674,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - return messaging.sendEach([validMessage, validMessage, validMessage]) + return legacyMessaging.sendEach([validMessage, validMessage, validMessage]) .then((response: BatchResponse) => { expect(response.successCount).to.equal(3); expect(response.failureCount).to.equal(0); @@ -667,7 +708,7 @@ describe('Messaging', () => { // for more context. arrayLike.constructor = CustomArray; - return messaging.sendEach(arrayLike) + return legacyMessaging.sendEach(arrayLike) .then((response: BatchResponse) => { expect(response.successCount).to.equal(3); expect(response.failureCount).to.equal(0); @@ -686,7 +727,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - return messaging.sendEach([validMessage, validMessage, validMessage], true) + return legacyMessaging.sendEach([validMessage, validMessage, validMessage], true) .then((response: BatchResponse) => { expect(response.successCount).to.equal(3); expect(response.failureCount).to.equal(0); @@ -712,7 +753,7 @@ describe('Messaging', () => { ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) errors.forEach(error => mockedRequests.push(mockSendError(400, 'json', error))) - return messaging.sendEach([validMessage, validMessage, validMessage], true) + return legacyMessaging.sendEach([validMessage, validMessage, validMessage], true) .then((response: BatchResponse) => { expect(response.successCount).to.equal(2); expect(response.failureCount).to.equal(1); @@ -726,7 +767,7 @@ describe('Messaging', () => { }); }); - it('should be fulfilled with a BatchResponse for all failures given an app which' + + it('should be fulfilled with a BatchResponse for all failures given an app which ' + 'returns null access tokens', () => { return nullAccessTokenMessaging.sendEach( [validMessage, validMessage], @@ -757,7 +798,7 @@ describe('Messaging', () => { ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) errors.forEach(error => mockedRequests.push(mockSendError(404, 'json', error))) - return messaging.sendEach([validMessage, validMessage], true) + return legacyMessaging.sendEach([validMessage, validMessage], true) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); expect(response.failureCount).to.equal(1); @@ -780,7 +821,7 @@ describe('Messaging', () => { mockedRequests.push(mockSendError(404, 'json', error)); mockedRequests.push(mockSendError(400, 'json', { error: 'test error message' })); mockedRequests.push(mockSendError(400, 'text', 'foo bar')); - return messaging.sendEach( + return legacyMessaging.sendEach( [validMessage, validMessage, validMessage], ).then((response: BatchResponse) => { expect(response.failureCount).to.equal(3); @@ -805,8 +846,222 @@ describe('Messaging', () => { messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - return messaging.sendEach(messages) + return legacyMessaging.sendEach(messages) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); + + it('should be fulfilled with a BatchResponse given valid messages using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + http2Mocker.http2Stub(mockedHttp2Responses) + + return messaging.sendEach([validMessage, validMessage, validMessage], false) .then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(3); + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + expect(response.responses.length).to.equal(3); + response.responses.forEach((resp, idx) => { + checkSendResponseSuccess(resp, messageIds[idx]); + }); + }); + }); + + it('should be fulfilled with a BatchResponse given array-like (issue #566) using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + http2Mocker.http2Stub(mockedHttp2Responses) + + const message = { + token: 'a', + android: { + ttl: 3600, + }, + }; + const arrayLike = new CustomArray(); + arrayLike.push(message); + arrayLike.push(message); + arrayLike.push(message); + // Explicitly patch the constructor so that down compiling to ES5 doesn't affect the test. + // See https://github.com/firebase/firebase-admin-node/issues/566#issuecomment-501974238 + // for more context. + arrayLike.constructor = CustomArray; + + return messaging.sendEach(arrayLike, false) + .then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(3); + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); + + it('should be fulfilled with a BatchResponse given valid messages in dryRun mode using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + http2Mocker.http2Stub(mockedHttp2Responses) + + return messaging.sendEach([validMessage, validMessage, validMessage], true) + .then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(3); + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + expect(response.responses.length).to.equal(3); + response.responses.forEach((resp, idx) => { + checkSendResponseSuccess(resp, messageIds[idx]); + }); + }); + }); + + it('should be fulfilled with a BatchResponse for partial failures using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + }, + }, + ]; + + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) + http2Mocker.http2Stub(mockedHttp2Responses) + + return messaging.sendEach([validMessage, validMessage, validMessage], true) + .then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(3); + expect(response.successCount).to.equal(2); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(3); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseSuccess(responses[1], messageIds[1]); + checkSendResponseFailure( + responses[2], 'messaging/invalid-argument', 'test error message'); + }); + }); + + it('should be fulfilled with a BatchResponse for all failures given an app which ' + + 'returns null access tokens using HTTP/2', () => { + return nullAccessTokenMessaging.sendEach( + [validMessage, validMessage], false).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); + }); + }); + + it('should expose the FCM error code in a detailed error via BatchResponse using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + ]; + const errors = [ + { + error: { + ':status': 'INVALID_ARGUMENT', + message: 'test error message', + details: [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + }, + }, + ]; + + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) + http2Mocker.http2Stub(mockedHttp2Responses) + + return messaging.sendEach([validMessage, validMessage], true) + .then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(2); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseFailure( + responses[1], 'messaging/registration-token-not-registered'); + }); + }); + + it('should map server error code to client-side error using HTTP/2', () => { + const error = { + error: { + status: 'NOT_FOUND', + message: 'test error message1', + } + }; + mockedHttp2Responses.push(mockHttp2SendRequestError(404, 'json', error)); + mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', { error: 'test error message2' })); + mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'text', 'foo bar')); + http2Mocker.http2Stub(mockedHttp2Responses) + + return messaging.sendEach( + [validMessage, validMessage, validMessage], false + ).then((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(3); + expect(response.failureCount).to.equal(3); + const responses = response.responses; + checkSendResponseFailure(responses[0], 'messaging/registration-token-not-registered'); + checkSendResponseFailure(responses[1], 'messaging/unknown-error'); + checkSendResponseFailure(responses[2], 'messaging/invalid-argument'); + }); + }); + + // This test was added to also verify https://github.com/firebase/firebase-admin-node/issues/1146 + it('should be fulfilled when called with different message types using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + const tokenMessage: TokenMessage = { token: 'test' }; + const topicMessage: TopicMessage = { topic: 'test' }; + const conditionMessage: ConditionMessage = { condition: 'test' }; + const messages: Message[] = [tokenMessage, topicMessage, conditionMessage]; + + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + http2Mocker.http2Stub(mockedHttp2Responses) + + return messaging.sendEach(messages, false) + .then ((response: BatchResponse) => { + expect(http2Mocker.requests.length).to.equal(3); expect(response.successCount).to.equal(3); expect(response.failureCount).to.equal(0); response.responses.forEach((resp, idx) => { @@ -831,6 +1086,21 @@ describe('Messaging', () => { let stub: sinon.SinonStub | null; + function checkSendResponseSuccess(response: SendResponse, messageId: string): void { + expect(response.success).to.be.true; + expect(response.messageId).to.equal(messageId); + expect(response.error).to.be.undefined; + } + + function checkSendResponseFailure(response: SendResponse, code: string, msg?: string): void { + expect(response.success).to.be.false; + expect(response.messageId).to.be.undefined; + expect(response.error).to.have.property('code', code); + if (msg) { + expect(response.error!.toString()).to.contain(msg); + } + } + afterEach(() => { if (stub) { stub.restore(); @@ -869,6 +1139,7 @@ describe('Messaging', () => { }); }); + it('should create multiple messages using the empty multicast payload', () => { stub = sinon.stub(messaging, 'sendEach').resolves(mockResponse); const tokens = ['a', 'b', 'c']; @@ -879,6 +1150,7 @@ describe('Messaging', () => { const messages: Message[] = stub!.args[0][0]; expect(messages.length).to.equal(3); expect(stub!.args[0][1]).to.be.undefined; + expect(stub!.args[0][2]).to.be.undefined; messages.forEach((message, idx) => { expect((message as TokenMessage).token).to.equal(tokens[idx]); expect(message.android).to.be.undefined; @@ -909,6 +1181,7 @@ describe('Messaging', () => { const messages: Message[] = stub!.args[0][0]; expect(messages.length).to.equal(3); expect(stub!.args[0][1]).to.be.undefined; + expect(stub!.args[0][2]).to.be.undefined; messages.forEach((message, idx) => { expect((message as TokenMessage).token).to.equal(tokens[idx]); expect(message.android).to.deep.equal(multicast.android); @@ -929,6 +1202,7 @@ describe('Messaging', () => { expect(response).to.deep.equal(mockResponse); expect(stub).to.have.been.calledOnce; expect(stub!.args[0][1]).to.be.true; + expect(stub!.args[0][2]).to.be.undefined; }); }); @@ -939,7 +1213,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - return messaging.sendEachForMulticast({ + return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'], android: { ttl: 100 }, apns: { payload: { aps: { badge: 42 } } }, @@ -964,7 +1238,7 @@ describe('Messaging', () => { 'projects/projec_id/messages/3', ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) - return messaging.sendEachForMulticast({ + return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'], android: { ttl: 100 }, apns: { payload: { aps: { badge: 42 } } }, @@ -996,7 +1270,7 @@ describe('Messaging', () => { ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))) - return messaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'] }) + return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'] }) .then((response: BatchResponse) => { expect(response.successCount).to.equal(2); expect(response.failureCount).to.equal(1); @@ -1041,7 +1315,7 @@ describe('Messaging', () => { ]; messageIds.forEach(id => mockedRequests.push(mockSendRequest(id))) errors.forEach(err => mockedRequests.push(mockSendError(400, 'json', err))) - return messaging.sendEachForMulticast({ tokens: ['a', 'b'] }) + return legacyMessaging.sendEachForMulticast({ tokens: ['a', 'b'] }) .then((response: BatchResponse) => { expect(response.successCount).to.equal(1); expect(response.failureCount).to.equal(1); @@ -1064,7 +1338,7 @@ describe('Messaging', () => { mockedRequests.push(mockSendError(404, 'json', error)); mockedRequests.push(mockSendError(400, 'json', { error: 'test error message' })); mockedRequests.push(mockSendError(400, 'text', 'foo bar')); - return messaging.sendEachForMulticast( + return legacyMessaging.sendEachForMulticast( { tokens: ['a', 'a', 'a'] }, ).then((response: BatchResponse) => { expect(response.failureCount).to.equal(3); @@ -1075,20 +1349,153 @@ describe('Messaging', () => { }); }); - function checkSendResponseSuccess(response: SendResponse, messageId: string): void { - expect(response.success).to.be.true; - expect(response.messageId).to.equal(messageId); - expect(response.error).to.be.undefined; - } + it('should be fulfilled with a BatchResponse given valid message using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + http2Mocker.http2Stub(mockedHttp2Responses) + return messaging.sendEachForMulticast({ + tokens: ['a', 'b', 'c'], + android: { ttl: 100 }, + apns: { payload: { aps: { badge: 42 } } }, + data: { key: 'value' }, + notification: { title: 'test title' }, + webpush: { data: { webKey: 'webValue' } }, + }, false).then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + response.responses.forEach((resp, idx) => { + expect(resp.success).to.be.true; + expect(resp.messageId).to.equal(messageIds[idx]); + expect(resp.error).to.be.undefined; + }); + }); + }); - function checkSendResponseFailure(response: SendResponse, code: string, msg?: string): void { - expect(response.success).to.be.false; - expect(response.messageId).to.be.undefined; - expect(response.error).to.have.property('code', code); - if (msg) { - expect(response.error!.toString()).to.contain(msg); - } - } + it('should be fulfilled with a BatchResponse given valid message in dryRun mode using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + 'projects/projec_id/messages/3', + ]; + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + http2Mocker.http2Stub(mockedHttp2Responses) + return messaging.sendEachForMulticast({ + tokens: ['a', 'b', 'c'], + android: { ttl: 100 }, + apns: { payload: { aps: { badge: 42 } } }, + data: { key: 'value' }, + notification: { title: 'test title' }, + webpush: { data: { webKey: 'webValue' } }, + }, true).then((response: BatchResponse) => { + expect(response.successCount).to.equal(3); + expect(response.failureCount).to.equal(0); + expect(response.responses.length).to.equal(3); + response.responses.forEach((resp, idx) => { + checkSendResponseSuccess(resp, messageIds[idx]); + }); + }); + }); + + it('should be fulfilled with a BatchResponse for partial failures using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + 'projects/projec_id/messages/2', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + }, + }, + ]; + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) + http2Mocker.http2Stub(mockedHttp2Responses) + return messaging.sendEachForMulticast({ tokens: ['a', 'b', 'c'] }, false) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(2); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(3); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseSuccess(responses[1], messageIds[1]); + checkSendResponseFailure( + responses[2], 'messaging/invalid-argument', 'test error message'); + }); + }); + + it('should be fulfilled with a BatchResponse for all failures given an app which ' + + 'returns null access tokens using HTTP/2', () => { + return nullAccessTokenMessaging.sendEachForMulticast( + { tokens: ['a', 'a'] }, false + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(2); + response.responses.forEach(resp => checkSendResponseFailure( + resp, 'app/invalid-credential')); + }); + }); + + it('should expose the FCM error code in a detailed error via BatchResponse using HTTP/2', () => { + const messageIds = [ + 'projects/projec_id/messages/1', + ]; + const errors = [ + { + error: { + status: 'INVALID_ARGUMENT', + message: 'test error message', + details: [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + }, + }, + ]; + messageIds.forEach(id => mockedHttp2Responses.push(mockHttp2SendRequestResponse(id))) + errors.forEach(error => mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', error))) + http2Mocker.http2Stub(mockedHttp2Responses) + return messaging.sendEachForMulticast({ tokens: ['a', 'b'] }, false) + .then((response: BatchResponse) => { + expect(response.successCount).to.equal(1); + expect(response.failureCount).to.equal(1); + expect(response.responses.length).to.equal(2); + + const responses = response.responses; + checkSendResponseSuccess(responses[0], messageIds[0]); + checkSendResponseFailure( + responses[1], 'messaging/registration-token-not-registered'); + }); + }); + + it('should map server error code to client-side error using HTTP/2', () => { + const error = { + error: { + status: 'NOT_FOUND', + message: 'test error message', + } + }; + mockedHttp2Responses.push(mockHttp2SendRequestError(404, 'json', error)); + mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'json', { error: 'test error message2' })); + mockedHttp2Responses.push(mockHttp2SendRequestError(400, 'text', 'foo bar')); + http2Mocker.http2Stub(mockedHttp2Responses) + return messaging.sendEachForMulticast( + { tokens: ['a', 'a', 'a'] }, false + ).then((response: BatchResponse) => { + expect(response.failureCount).to.equal(3); + const responses = response.responses; + checkSendResponseFailure(responses[0], 'messaging/registration-token-not-registered'); + checkSendResponseFailure(responses[1], 'messaging/unknown-error'); + checkSendResponseFailure(responses[2], 'messaging/invalid-argument'); + }); + }); }); describe('sendAll()', () => { diff --git a/test/unit/utils.ts b/test/unit/utils.ts index 2eb608397e..6ff00d5e26 100644 --- a/test/unit/utils.ts +++ b/test/unit/utils.ts @@ -20,13 +20,13 @@ import * as sinon from 'sinon'; import * as mocks from '../resources/mocks'; import { AppOptions } from '../../src/firebase-namespace-api'; import { FirebaseApp, FirebaseAppInternals, FirebaseAccessToken } from '../../src/app/firebase-app'; -import { HttpError, HttpResponse } from '../../src/utils/api-request'; +import { RequestResponseError, RequestResponse } from '../../src/utils/api-request'; /** - * Returns a new FirebaseApp instance with the provided options. + * Returns a new `FirebaseApp` instance with the provided options. * - * @param options The options for the FirebaseApp instance to create. - * @return A new FirebaseApp instance with the provided options. + * @param options The options for the `FirebaseApp` instance to create. + * @return A new `FirebaseApp` instance with the provided options. */ export function createAppWithOptions(options: object): FirebaseApp { return new FirebaseApp(options as AppOptions, mocks.appName); @@ -39,7 +39,7 @@ export function generateRandomAccessToken(): string { } /** - * Creates a stub for retrieving an access token from a FirebaseApp. All services should use this + * Creates a stub for retrieving an access token from a `FirebaseApp`. All services should use this * method for stubbing the OAuth2 flow during unit tests. * * @param {string} accessToken The access token string to return. @@ -69,7 +69,7 @@ export function stubGetAccessToken(accessToken?: string, app?: FirebaseApp): sin * @param {*=} headers HTTP headers to be included in the ersponse. * @return {HttpResponse} An HTTP response object. */ -export function responseFrom(data: object | string, status = 200, headers: any = {}): HttpResponse { +export function responseFrom(data: object | string, status = 200, headers: any = {}): RequestResponse { let responseData: any; let responseText: string; if (typeof data === 'object') { @@ -92,6 +92,6 @@ export function responseFrom(data: object | string, status = 200, headers: any = }; } -export function errorFrom(data: any, status = 500): HttpError { - return new HttpError(responseFrom(data, status)); +export function errorFrom(data: any, status = 500): RequestResponseError { + return new RequestResponseError(responseFrom(data, status)); } diff --git a/test/unit/utils/api-request.spec.ts b/test/unit/utils/api-request.spec.ts index fd92a9c191..4fcb5a397b 100644 --- a/test/unit/utils/api-request.spec.ts +++ b/test/unit/utils/api-request.spec.ts @@ -28,8 +28,9 @@ import * as mocks from '../../resources/mocks'; import { FirebaseApp } from '../../../src/app/firebase-app'; import { - ApiSettings, HttpClient, HttpError, AuthorizedHttpClient, ApiCallbackFunction, HttpRequestConfig, - HttpResponse, parseHttpResponse, RetryConfig, defaultRetryConfig, + ApiSettings, HttpClient, Http2Client, AuthorizedHttpClient, ApiCallbackFunction, HttpRequestConfig, + parseHttpResponse, RetryConfig, defaultRetryConfig, Http2SessionHandler, Http2RequestConfig, + RequestResponseError, RequestResponse, AuthorizedHttp2Client, } from '../../../src/utils/api-request'; import { deepCopy } from '../../../src/utils/deep-copy'; import { Agent } from 'http'; @@ -43,7 +44,8 @@ const expect = chai.expect; const mockHost = 'www.example.com'; const mockPath = '/foo/bar'; -const mockUrl = `https://${mockHost}${mockPath}`; +const mockHostUrl = `https://${mockHost}`; +const mockUrl = `${mockHostUrl}${mockPath}`; const mockErrorResponse = { error: { @@ -93,6 +95,55 @@ function mockRequestWithError(err: any): nock.Scope { .replyWithError(err); } +function mockHttp2SendRequestResponse( + statusCode: number, + headers: any, + response:any, + delay?: number +): mocks.MockHttp2Response { + if (headers['content-type'] === 'application/json') { + response = JSON.stringify(response) + } + + return { + headers: { + ':status': statusCode, + ...headers + }, + data: Buffer.from(response), + delay: delay + } as mocks.MockHttp2Response +} + +function mockHttp2SendRequestError( + statusCode = 400, + headers: any, + response: any = mockErrorResponse, + delay?: number +): mocks.MockHttp2Response { + if (headers['content-type'] === 'application/json') { + response = JSON.stringify(response) + } + else if (headers['content-type'] === 'text/html') { + response = mockTextErrorResponse; + } + + return { + headers: { + ':status': statusCode, + ...headers + }, + data: Buffer.from(response), + delay: delay + } as mocks.MockHttp2Response +} + +function mockHttp2Error(err: any): mocks.MockHttp2Response { + return { + error: err + } as mocks.MockHttp2Response +} + /** * Returns a new RetryConfig instance for testing. This is same as the default * RetryConfig, with the backOffFactor set to 0 to avoid delays. @@ -625,7 +676,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 400.'); const resp = err.response; expect(resp.status).to.equal(400); @@ -642,7 +693,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 500.'); const resp = err.response; expect(resp.status).to.equal(500); @@ -663,7 +714,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 500.'); const resp = err.response; expect(resp.status).to.equal(500); @@ -757,7 +808,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 503.'); const resp = err.response; expect(resp.status).to.equal(503); @@ -889,7 +940,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 503.'); const resp = err.response; expect(resp.status).to.equal(503); @@ -915,7 +966,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 503.'); const resp = err.response; expect(resp.status).to.equal(503); @@ -939,7 +990,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 503.'); const resp = err.response; expect(resp.status).to.equal(503); @@ -970,7 +1021,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 503.'); const resp = err.response; expect(resp.status).to.equal(503); @@ -1000,7 +1051,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).catch((err: HttpError) => { + }).catch((err: RequestResponseError) => { expect(err.message).to.equal('Server responded with status 503.'); const resp = err.response; expect(resp.status).to.equal(503); @@ -1035,7 +1086,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).then((resp: HttpResponse) => { + }).then((resp: RequestResponse) => { expect(resp.status).to.equal(200); expect(resp.headers['content-type']).to.equal('application/json'); expect(resp.data).to.deep.equal(respData); @@ -1071,7 +1122,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).then((resp: HttpResponse) => { + }).then((resp: RequestResponse) => { expect(resp.status).to.equal(200); expect(resp.headers['content-type']).to.equal('application/json'); expect(resp.data).to.deep.equal(respData); @@ -1105,7 +1156,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).then((resp: HttpResponse) => { + }).then((resp: RequestResponse) => { expect(resp.status).to.equal(200); expect(resp.headers['content-type']).to.equal('application/json'); expect(resp.data).to.deep.equal(respData); @@ -1137,7 +1188,7 @@ describe('HttpClient', () => { return client.send({ method: 'GET', url: mockUrl, - }).then((resp: HttpResponse) => { + }).then((resp: RequestResponse) => { expect(resp.status).to.equal(200); expect(resp.headers['content-type']).to.equal('application/json'); expect(resp.data).to.deep.equal(respData); @@ -1193,155 +1244,1411 @@ describe('HttpClient', () => { }); }); -describe('AuthorizedHttpClient', () => { - let mockApp: FirebaseApp; - let mockedRequests: nock.Scope[] = []; - let getTokenStub: sinon.SinonStub; +describe('Http2Client', () => { + let mockedHttp2Responses: mocks.MockHttp2Response[] = []; + const http2Mocker: mocks.Http2Mocker = new mocks.Http2Mocker(); + let http2SessionHandler: Http2SessionHandler; + let delayStub: sinon.SinonStub | null = null; + let clock: sinon.SinonFakeTimers | null = null; - const mockAccessToken: string = utils.generateRandomAccessToken(); - const requestHeaders = { - reqheaders: { - Authorization: `Bearer ${mockAccessToken}`, - }, - }; + const sampleMultipartData = '--boundary\r\n' + + 'Content-type: application/json\r\n\r\n' + + '{"foo": 1}\r\n' + + '--boundary\r\n' + + 'Content-type: text/plain\r\n\r\n' + + 'foo bar\r\n' + + '--boundary--\r\n'; - before(() => { - getTokenStub = utils.stubGetAccessToken(mockAccessToken); + afterEach(() => { + if ( http2SessionHandler) { + http2SessionHandler.close() + } + if (delayStub) { + delayStub.restore(); + delayStub = null; + } + if (clock) { + clock.restore(); + clock = null; + } + http2Mocker.done() + mockedHttp2Responses = []; }); - after(() => { - getTokenStub.restore(); + const invalidNumbers: any[] = ['string', null, undefined, {}, [], true, false, NaN, -1]; + const invalidArrays: any[] = ['string', null, {}, true, false, NaN, 0, 1]; + + invalidNumbers.forEach((maxRetries: any) => { + it(`should throw when maxRetries is: ${maxRetries}`, () => { + expect(() => { + new Http2Client({ maxRetries } as any); + }).to.throw('maxRetries must be a non-negative integer'); + }); }); - beforeEach(() => { - mockApp = mocks.app(); + invalidNumbers.forEach((backOffFactor: any) => { + if (typeof backOffFactor !== 'undefined') { + it(`should throw when backOffFactor is: ${backOffFactor}`, () => { + expect(() => { + new Http2Client({ maxRetries: 1, backOffFactor } as any); + }).to.throw('backOffFactor must be a non-negative number'); + }); + } }); - afterEach(() => { - mockedRequests.forEach((mockedRequest) => mockedRequest.done()); - mockedRequests = []; - return mockApp.delete(); + invalidNumbers.forEach((maxDelayInMillis: any) => { + it(`should throw when maxDelayInMillis is: ${maxDelayInMillis}`, () => { + expect(() => { + new Http2Client({ maxRetries: 1, maxDelayInMillis } as any); + }).to.throw('maxDelayInMillis must be a non-negative integer'); + }); + }); + + invalidArrays.forEach((ioErrorCodes: any) => { + it(`should throw when ioErrorCodes is: ${ioErrorCodes}`, () => { + expect(() => { + new HttpClient({ maxRetries: 1, maxDelayInMillis: 10000, ioErrorCodes } as any); + }).to.throw('ioErrorCodes must be an array'); + }); + }); + + invalidArrays.forEach((statusCodes: any) => { + it(`should throw when statusCodes is: ${statusCodes}`, () => { + expect(() => { + new HttpClient({ maxRetries: 1, maxDelayInMillis: 10000, statusCodes } as any); + }).to.throw('statusCodes must be an array'); + }); }); it('should be fulfilled for a 2xx response with a json payload', () => { const respData = { foo: 'bar' }; - const scope = nock('https://' + mockHost, requestHeaders) - .get(mockPath) - .reply(200, respData, { - 'content-type': 'application/json', - }); - mockedRequests.push(scope); - const client = new AuthorizedHttpClient(mockApp); + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + return client.send({ method: 'GET', url: mockUrl, + http2SessionHandler: http2SessionHandler, }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); expect(resp.status).to.equal(200); expect(resp.headers['content-type']).to.equal('application/json'); expect(resp.text).to.equal(JSON.stringify(respData)); expect(resp.data).to.deep.equal(respData); + expect(resp.multipart).to.be.undefined; + expect(resp.isJson()).to.be.true; }); }); - describe('HTTP Agent', () => { - let transportSpy: sinon.SinonSpy | null = null; - let mockAppWithAgent: FirebaseApp; - let agentForApp: Agent; + it('should be fulfilled for a 2xx response with a text payload', () => { + const respData = 'foo bar'; + const headers = { 'content-type': 'text/plain' }; - beforeEach(() => { - const options = mockApp.options; - options.httpAgent = new Agent(); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const https = require('https'); - transportSpy = sinon.spy(https, 'request'); - mockAppWithAgent = mocks.appWithOptions(options); - agentForApp = options.httpAgent; + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('text/plain'); + expect(resp.text).to.equal(respData); + expect(() => { resp.data; }).to.throw('Error while parsing response data'); + expect(resp.multipart).to.be.undefined; + expect(resp.isJson()).to.be.false; }); + }); - afterEach(() => { - transportSpy!.restore(); - transportSpy = null; - return mockAppWithAgent.delete(); + it('should be fulfilled for a 2xx response with an empty multipart payload', () => { + const respData = '--boundary--\r\n'; + const headers = { 'content-type': 'multipart/mixed; boundary=boundary' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('multipart/mixed; boundary=boundary'); + expect(resp.multipart).to.not.be.undefined; + expect(resp.multipart!.length).to.equal(0); + expect(() => { resp.text; }).to.throw('Unable to parse multipart payload as text'); + expect(() => { resp.data; }).to.throw('Unable to parse multipart payload as JSON'); + expect(resp.isJson()).to.be.false; }); + }); - it('should use the HTTP agent set in request', () => { - const respData = { success: true }; - const scope = nock('https://' + mockHost, requestHeaders) - .get(mockPath) - .reply(200, respData, { - 'content-type': 'application/json', - }); - mockedRequests.push(scope); - const client = new AuthorizedHttpClient(mockAppWithAgent); - const httpAgent = new Agent(); - return client.send({ - method: 'GET', - url: mockUrl, - httpAgent, - }).then((resp) => { - expect(resp.status).to.equal(200); - expect(transportSpy!.callCount).to.equal(1); - const options = transportSpy!.args[0][0]; - expect(options.agent).to.equal(httpAgent); - }); + it('should be fulfilled for a 2xx response with a multipart payload', () => { + const headers = { 'content-type': 'multipart/mixed; boundary=boundary' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, sampleMultipartData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('multipart/mixed; boundary=boundary'); + expect(resp.multipart).to.exist; + expect(resp.multipart!.map((buffer) => buffer.toString('utf-8'))).to.deep.equal(['{"foo": 1}', 'foo bar']); + expect(() => { resp.text; }).to.throw('Unable to parse multipart payload as text'); + expect(() => { resp.data; }).to.throw('Unable to parse multipart payload as JSON'); + expect(resp.isJson()).to.be.false; }); + }); - it('should use the HTTP agent set in AppOptions', () => { - const respData = { success: true }; - const scope = nock('https://' + mockHost, requestHeaders) - .get(mockPath) - .reply(200, respData, { - 'content-type': 'application/json', - }); - mockedRequests.push(scope); - const client = new AuthorizedHttpClient(mockAppWithAgent); - return client.send({ - method: 'GET', - url: mockUrl, - }).then((resp) => { - expect(resp.status).to.equal(200); - expect(transportSpy!.callCount).to.equal(1); - const options = transportSpy!.args[0][0]; - expect(options.agent).to.equal(agentForApp); - }); + it('should be fulfilled for a 2xx response with any multipart payload', () => { + const headers = { 'content-type': 'multipart/something; boundary=boundary' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, sampleMultipartData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('multipart/something; boundary=boundary'); + expect(resp.multipart).to.exist; + expect(resp.multipart!.map((buffer) => buffer.toString('utf-8'))).to.deep.equal(['{"foo": 1}', 'foo bar']); + expect(() => { resp.text; }).to.throw('Unable to parse multipart payload as text'); + expect(() => { resp.data; }).to.throw('Unable to parse multipart payload as JSON'); + expect(resp.isJson()).to.be.false; }); }); - it('should make a POST request with the provided headers and data', () => { - const reqData = { request: 'data' }; - const respData = { success: true }; - const options = { - reqheaders: { - 'Content-Type': (header: string) => { - return header.startsWith('application/json'); // auto-inserted - }, - 'My-Custom-Header': 'CustomValue', - }, - }; - Object.assign(options.reqheaders, requestHeaders.reqheaders); - const scope = nock('https://' + mockHost, options) - .post(mockPath, reqData) - .reply(200, respData, { - 'content-type': 'application/json', - }); - mockedRequests.push(scope); - const client = new AuthorizedHttpClient(mockApp); + it('should handle as a text response when boundary not present', () => { + const respData = 'foo bar'; + const headers = { 'content-type': 'multipart/mixed' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + return client.send({ - method: 'POST', + method: 'GET', url: mockUrl, - headers: { - 'My-Custom-Header': 'CustomValue', - }, - data: reqData, + http2SessionHandler: http2SessionHandler, }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); expect(resp.status).to.equal(200); - expect(resp.headers['content-type']).to.equal('application/json'); - expect(resp.data).to.deep.equal(respData); + expect(resp.headers['content-type']).to.equal('multipart/mixed'); + expect(resp.multipart).to.be.undefined; + expect(resp.text).to.equal(respData); + expect(() => { resp.data; }).to.throw('Error while parsing response data'); + expect(resp.isJson()).to.be.false; }); }); - it('should not mutate the arguments', () => { + it('should be fulfilled for a 2xx response with a compressed payload', () => { + const deflated: Buffer = zlib.deflateSync('foo bar'); + const headers = { 'content-type': 'text/plain', 'content-encoding': 'deflate' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, deflated)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('text/plain'); + expect(resp.headers['content-encoding']).to.be.undefined; + expect(resp.multipart).to.be.undefined; + expect(resp.text).to.equal('foo bar'); + expect(() => { resp.data; }).to.throw('Error while parsing response data'); + expect(resp.isJson()).to.be.false; + }); + }); + + it('should use the default RetryConfig', () => { + const client = new Http2Client(); + const config = (client as any).retry as RetryConfig; + expect(defaultRetryConfig()).to.deep.equal(config); + }); + + it('should make a POST request with the provided headers and data', () => { + const reqData = { request: 'data' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'POST', + url: mockUrl, + headers: { + 'authorization': 'Bearer token', + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('POST'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(JSON.parse(http2Mocker.requests[0].data)).to.deep.equal(reqData); + expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token'); + expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json'); + expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should use the specified content-type header for the body', () => { + const reqData = { request: 'data' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'POST', + url: mockUrl, + headers: { + 'content-type': 'custom/type', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('POST'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(JSON.parse(http2Mocker.requests[0].data)).to.deep.equal(reqData); + expect(http2Mocker.requests[0].headers['content-type']).to.contain('custom/type'); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should not mutate the arguments', () => { + const reqData = { request: 'data' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse( + 200, + { 'content-type': 'application/json' }, + { success: true } + )); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + const request: Http2RequestConfig = { + method: 'POST', + url: mockUrl, + headers: { + 'authorization': 'Bearer token', + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }; + const requestCopy = deepCopy(request); + + return client.send(request).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('POST'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(JSON.parse(http2Mocker.requests[0].data)).to.deep.equal(reqData); + expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json'); + expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token'); + expect(resp.status).to.equal(200); + expect(request).to.deep.equal(requestCopy); + }); + }); + + it('should make a GET request with the provided headers and data', () => { + const reqData = { key1: 'value1', key2: 'value2' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + headers: { + 'authorization': 'Bearer token', + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(`${mockPath}?key1=value1&key2=value2`); + expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token'); + expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should merge query parameters in URL with data', () => { + const reqData = { key1: 'value1', key2: 'value2' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl + '?key3=value3', + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(`${mockPath}?key3=value3&key1=value1&key2=value2`); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should urlEncode query parameters in URL', () => { + const reqData = { key1: 'value 1!', key2: 'value 2!' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl + '?key3=value+3%21', + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']) + .to.equal(`${mockPath}?key3=value+3%21&key1=value+1%21&key2=value+2%21`); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should default to https when protocol not specified', () => { + const respData = { foo: 'bar' }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl.substring('https://'.length), + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.text).to.equal(JSON.stringify(respData)); + expect(resp.data).to.deep.equal(respData); + expect(resp.multipart).to.be.undefined; + expect(resp.isJson()).to.be.true; + }); + }); + + it('should fail with a GET request containing non-object data', () => { + const err = 'GET requests cannot have a body.'; + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + timeout: 50, + data: 'non-object-data', + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error'); + }); + + it('should make a HEAD request with the provided headers and data', () => { + const reqData = { key1: 'value1', key2: 'value2' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'HEAD', + url: mockUrl, + headers: { + 'authorization': 'Bearer token', + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('HEAD'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(`${mockPath}?key1=value1&key2=value2`); + expect(http2Mocker.requests[0].headers.authorization).to.equal('Bearer token'); + expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should fail with a HEAD request containing non-object data', () => { + const err = 'HEAD requests cannot have a body.'; + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'HEAD', + url: mockUrl, + timeout: 50, + data: 'non-object-data', + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error'); + }); + + it('should fail with an HttpError for a 4xx response', () => { + const data = { error: 'data' }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestError(400, headers, data)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(err.message).to.equal('Server responded with status 400.'); + const resp = err.response; + expect(resp.status).to.equal(400); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(data); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should fail with an HttpError for a 5xx response', () => { + const data = { error: 'data' }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestError(500, headers, data)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(err.message).to.equal('Server responded with status 500.'); + const resp = err.response; + expect(resp.status).to.equal(500); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(data); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should fail for an error response with a multipart payload', () => { + const headers = { 'content-type': 'multipart/mixed; boundary=boundary' }; + + mockedHttp2Responses.push(mockHttp2SendRequestError(500, headers, sampleMultipartData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(err.message).to.equal('Server responded with status 500.'); + const resp = err.response; + expect(resp.status).to.equal(500); + expect(resp.headers['content-type']).to.equal('multipart/mixed; boundary=boundary'); + expect(resp.multipart).to.exist; + expect(resp.multipart!.map((buffer) => buffer.toString('utf-8'))).to.deep.equal(['{"foo": 1}', 'foo bar']); + expect(() => { resp.text; }).to.throw('Unable to parse multipart payload as text'); + expect(() => { resp.data; }).to.throw('Unable to parse multipart payload as JSON'); + expect(resp.isJson()).to.be.false; + }); + }); + + it('should fail with a FirebaseAppError for a network error', () => { + const err = 'Error while making request: test error. Error code: AWFUL_ERROR'; + + mockedHttp2Responses.push(mockHttp2Error({ message: 'test error', code: 'AWFUL_ERROR' })); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error') + .then(() => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + }); + }); + + it('should timeout when the response is repeatedly delayed', () => { + const err = 'Error while making request: timeout of 50ms exceeded.'; + const respData = { foo: 'bar' }; + const headers = { 'content-type': 'application/json' }; + + for (let i = 0; i < 5; i++) { + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData, 2000)); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(testRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + timeout: 50, + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-timeout') + .then(() => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + }); + }); + + it('should be rejected, after 4 retries, on multiple network errors', () => { + const err = 'Error while making request: connection reset 5'; + + for (let i = 0; i < 5; i++) { + mockedHttp2Responses.push(mockHttp2Error({ message: `connection reset ${i + 1}`, code: 'ECONNRESET' })); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(testRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + timeout: 50, + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error') + .then(() => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + }); + }); + + it('should be rejected, after 4 retries, on multiple 503 errors', () => { + const headers = { 'content-type': 'application/json' }; + + for (let i = 0; i < 5; i++) { + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(testRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(err.message).to.equal('Server responded with status 503.'); + const resp = err.response; + expect(resp.status).to.equal(503); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal({}); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should succeed, after 1 retry, on a single network error', () => { + const respData = { foo: 'bar' }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 1', code: 'ECONNRESET' })); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(defaultRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.data).to.deep.equal(respData); + }); + }); + + it('should not retry when RetryConfig is explicitly null', () => { + const err = 'Error while making request: connection reset 1'; + + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 1', code: 'ECONNRESET' })); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(null); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error') + .then(() => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + }); + }); + + it('should not retry when maxRetries is set to 0', () => { + const err = 'Error while making request: connection reset 1'; + + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 1', code: 'ECONNRESET' })); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client({ + maxRetries: 0, + ioErrorCodes: ['ECONNRESET'], + maxDelayInMillis: 10000, + }); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error') + .then(() => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + }); + }); + + it('should not retry when error codes are not configured', () => { + const err = 'Error while making request: connection reset 1'; + + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 1', code: 'ECONNRESET' })); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client({ + maxRetries: 1, + maxDelayInMillis: 10000, + }); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error') + .then(() => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + }); + }); + + it('should succeed after a retry on a configured I/O error', () => { + const respData = { foo: 'bar' }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 1', code: 'ETESTCODE' })); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client({ + maxRetries: 1, + maxDelayInMillis: 1000, + ioErrorCodes: ['ETESTCODE'], + }); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.data).to.deep.equal(respData); + }); + }); + + it('should succeed after a retry on a configured HTTP error', () => { + const respData = { foo: 'bar' }; + const headers = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(testRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.data).to.deep.equal(respData); + }); + }); + + it('should not retry more than maxRetries', () => { + const headers = { 'content-type': 'application/json' }; + + // simulate 2 low-level errors + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 1', code: 'ECONNRESET' })); + mockedHttp2Responses.push(mockHttp2Error({ message: 'connection reset 2', code: 'ECONNRESET' })); + // followed by 3 HTTP errors + for (let i = 0; i < 3; i++) { + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(testRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(err.message).to.equal('Server responded with status 503.'); + const resp = err.response; + expect(resp.status).to.equal(503); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal({}); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should not retry when retry-after exceeds maxDelayInMillis', () => { + const headers = { 'content-type': 'application/json', 'retry-after': '61' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client({ + maxRetries: 1, + maxDelayInMillis: 60 * 1000, + statusCodes: [503], + }); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(err.message).to.equal('Server responded with status 503.'); + const resp = err.response; + expect(resp.status).to.equal(503); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal({}); + expect(resp.isJson()).to.be.true; + }); + }); + + it('should retry with exponential back off', () => { + const headers = { 'content-type': 'application/json' }; + + for (let i = 0; i < 5; i++) { + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(defaultRetryConfig()); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(err.message).to.equal('Server responded with status 503.'); + const resp = err.response; + expect(resp.status).to.equal(503); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal({}); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(4); + const delays = delayStub!.args.map((args) => args[0]); + expect(delays).to.deep.equal([0, 1000, 2000, 4000]); + }); + }); + + it('delay should not exceed maxDelayInMillis', () => { + const headers = { 'content-type': 'application/json' }; + + for (let i = 0; i < 5; i++) { + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client({ + maxRetries: 4, + backOffFactor: 1, + maxDelayInMillis: 4 * 1000, + statusCodes: [503], + }); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(err.message).to.equal('Server responded with status 503.'); + const resp = err.response; + expect(resp.status).to.equal(503); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal({}); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(4); + const delays = delayStub!.args.map((args) => args[0]); + expect(delays).to.deep.equal([0, 2000, 4000, 4000]); + }); + }); + + it('should retry without delays when backOffFactor is not set', () => { + const headers = { 'content-type': 'application/json' }; + + for (let i = 0; i < 5; i++) { + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers, {})); + } + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client({ + maxRetries: 4, + maxDelayInMillis: 60 * 1000, + statusCodes: [503], + }); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler + }).catch((err: RequestResponseError) => { + expect(http2Mocker.requests.length).to.equal(5); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(err.message).to.equal('Server responded with status 503.'); + const resp = err.response; + expect(resp.status).to.equal(503); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal({}); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(4); + const delays = delayStub!.args.map((args) => args[0]); + expect(delays).to.deep.equal([0, 0, 0, 0]); + }); + }); + + it('should wait when retry-after expressed as seconds', () => { + const respData = { foo: 'bar' }; + const headers1 = { 'content-type': 'application/json', 'retry-after': '30' }; + const headers2 = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers1, {})); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers2, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(defaultRetryConfig()); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler + }).then((resp: RequestResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(1); + expect(delayStub!.args[0][0]).to.equal(30 * 1000); + }); + }); + + it('should wait when retry-after expressed as a timestamp', () => { + clock = sinon.useFakeTimers({ toFake: ['Date'] }); + clock.setSystemTime(1000); + + const timestamp = new Date(clock.now + 30 * 1000); + const respData = { foo: 'bar' }; + const headers1 = { 'content-type': 'application/json', 'retry-after': timestamp }; + const headers2 = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers1, {})); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers2, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(defaultRetryConfig()); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler + }).then((resp: RequestResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(1); + expect(delayStub!.args[0][0]).to.equal(30 * 1000); + }); + }); + + it('should not wait when retry-after timestamp is expired', () => { + const timestamp = new Date(Date.now() - 30 * 1000); + const respData = { foo: 'bar' }; + const headers1 = { 'content-type': 'application/json', 'retry-after': timestamp.toUTCString() }; + const headers2 = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers1, {})); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers2, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(defaultRetryConfig()); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler + }).then((resp: RequestResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(1); + expect(delayStub!.args[0][0]).to.equal(0); + }); + }); + + it('should not wait when retry-after is malformed', () => { + const respData = { foo: 'bar' }; + const headers1 = { 'content-type': 'application/json', 'retry-after': 'invalid' }; + const headers2 = { 'content-type': 'application/json' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(503, headers1, {})); + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers2, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new Http2Client(defaultRetryConfig()); + delayStub = sinon.stub(client as any, 'waitForRetry').resolves(); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler + }).then((resp: RequestResponse) => { + expect(http2Mocker.requests.length).to.equal(2); + http2Mocker.requests.forEach(request => { + expect(request.headers[':method']).to.equal('GET'); + expect(request.headers[':scheme']).to.equal('https:'); + expect(request.headers[':path']).to.equal(mockPath); + }); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + expect(resp.isJson()).to.be.true; + expect(delayStub!.callCount).to.equal(1); + expect(delayStub!.args[0][0]).to.equal(0); + }); + }); + + it('should reject if the request payload is invalid', () => { + const err = 'Error while making request: Request data must be a string, a Buffer ' + + 'or a json serializable object'; + + const client = new Http2Client(defaultRetryConfig()); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'POST', + url: mockUrl, + data: 1 as any, + http2SessionHandler: http2SessionHandler + }).should.eventually.be.rejectedWith(err).and.have.property('code', 'app/network-error'); + }); +}); + +describe('AuthorizedHttpClient', () => { + let mockApp: FirebaseApp; + let mockedRequests: nock.Scope[] = []; + let getTokenStub: sinon.SinonStub; + + const mockAccessToken: string = utils.generateRandomAccessToken(); + const requestHeaders = { + reqheaders: { + Authorization: `Bearer ${mockAccessToken}`, + }, + }; + + before(() => { + getTokenStub = utils.stubGetAccessToken(mockAccessToken); + }); + + after(() => { + getTokenStub.restore(); + }); + + beforeEach(() => { + mockApp = mocks.app(); + }); + + afterEach(() => { + mockedRequests.forEach((mockedRequest) => mockedRequest.done()); + mockedRequests = []; + return mockApp.delete(); + }); + + it('should be fulfilled for a 2xx response with a json payload', () => { + const respData = { foo: 'bar' }; + const scope = nock('https://' + mockHost, requestHeaders) + .get(mockPath) + .reply(200, respData, { + 'content-type': 'application/json', + }); + mockedRequests.push(scope); + const client = new AuthorizedHttpClient(mockApp); + return client.send({ + method: 'GET', + url: mockUrl, + }).then((resp) => { + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.text).to.equal(JSON.stringify(respData)); + expect(resp.data).to.deep.equal(respData); + }); + }); + + describe('HTTP Agent', () => { + let transportSpy: sinon.SinonSpy | null = null; + let mockAppWithAgent: FirebaseApp; + let agentForApp: Agent; + + beforeEach(() => { + const options = mockApp.options; + options.httpAgent = new Agent(); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const https = require('https'); + transportSpy = sinon.spy(https, 'request'); + mockAppWithAgent = mocks.appWithOptions(options); + agentForApp = options.httpAgent; + }); + + afterEach(() => { + transportSpy!.restore(); + transportSpy = null; + return mockAppWithAgent.delete(); + }); + + it('should use the HTTP agent set in request', () => { + const respData = { success: true }; + const scope = nock('https://' + mockHost, requestHeaders) + .get(mockPath) + .reply(200, respData, { + 'content-type': 'application/json', + }); + mockedRequests.push(scope); + const client = new AuthorizedHttpClient(mockAppWithAgent); + const httpAgent = new Agent(); + return client.send({ + method: 'GET', + url: mockUrl, + httpAgent, + }).then((resp) => { + expect(resp.status).to.equal(200); + expect(transportSpy!.callCount).to.equal(1); + const options = transportSpy!.args[0][0]; + expect(options.agent).to.equal(httpAgent); + }); + }); + + it('should use the HTTP agent set in AppOptions', () => { + const respData = { success: true }; + const scope = nock('https://' + mockHost, requestHeaders) + .get(mockPath) + .reply(200, respData, { + 'content-type': 'application/json', + }); + mockedRequests.push(scope); + const client = new AuthorizedHttpClient(mockAppWithAgent); + return client.send({ + method: 'GET', + url: mockUrl, + }).then((resp) => { + expect(resp.status).to.equal(200); + expect(transportSpy!.callCount).to.equal(1); + const options = transportSpy!.args[0][0]; + expect(options.agent).to.equal(agentForApp); + }); + }); + }); + + it('should make a POST request with the provided headers and data', () => { + const reqData = { request: 'data' }; + const respData = { success: true }; + const options = { + reqheaders: { + 'Content-Type': (header: string) => { + return header.startsWith('application/json'); // auto-inserted + }, + 'My-Custom-Header': 'CustomValue', + }, + }; + Object.assign(options.reqheaders, requestHeaders.reqheaders); + const scope = nock('https://' + mockHost, options) + .post(mockPath, reqData) + .reply(200, respData, { + 'content-type': 'application/json', + }); + mockedRequests.push(scope); + const client = new AuthorizedHttpClient(mockApp); + return client.send({ + method: 'POST', + url: mockUrl, + headers: { + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + }).then((resp) => { + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + }); + }); + + it('should not mutate the arguments', () => { const reqData = { request: 'data' }; const options = { reqheaders: { @@ -1375,6 +2682,135 @@ describe('AuthorizedHttpClient', () => { }); }); +describe('AuthorizedHttp2Client', () => { + let mockedHttp2Responses: mocks.MockHttp2Response[] = []; + const http2Mocker: mocks.Http2Mocker = new mocks.Http2Mocker(); + let http2SessionHandler: Http2SessionHandler; + let mockApp: FirebaseApp; + let getTokenStub: sinon.SinonStub; + + const mockAccessToken: string = utils.generateRandomAccessToken(); + + before(() => { + getTokenStub = utils.stubGetAccessToken(mockAccessToken); + }); + + after(() => { + getTokenStub.restore(); + }); + + beforeEach(() => { + mockApp = mocks.app(); + }); + + afterEach(() => { + if ( http2SessionHandler) { + http2SessionHandler.close() + } + http2Mocker.done() + mockedHttp2Responses = []; + return mockApp.delete(); + }); + + it('should be fulfilled for a 2xx response with a json payload', () => { + const respData = { foo: 'bar' }; + const headers = { 'content-type': 'application/json', 'Authorization': `Bearer ${mockAccessToken}` }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new AuthorizedHttp2Client(mockApp); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'GET', + url: mockUrl, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('GET'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.text).to.equal(JSON.stringify(respData)); + expect(resp.data).to.deep.equal(respData); + }); + }); + + it('should make a POST request with the provided headers and data', () => { + const reqData = { request: 'data' }; + const respData = { success: true }; + const headers = { 'content-type': 'application/json', 'Authorization': `Bearer ${mockAccessToken}` }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse(200, headers, respData)); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new AuthorizedHttp2Client(mockApp); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + return client.send({ + method: 'POST', + url: mockUrl, + headers: { + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('POST'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(JSON.parse(http2Mocker.requests[0].data)).to.deep.equal(reqData); + expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json'); + expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + expect(http2Mocker.requests[0].headers['Authorization']).to.contain('access_token_'); + expect(resp.status).to.equal(200); + expect(resp.headers['content-type']).to.equal('application/json'); + expect(resp.data).to.deep.equal(respData); + }); + }); + + it('should not mutate the arguments', () => { + const reqData = { request: 'data' }; + + mockedHttp2Responses.push(mockHttp2SendRequestResponse( + 200, + { 'content-type': 'application/json', 'Authorization': `Bearer ${mockAccessToken}` }, + { success: true } + )); + http2Mocker.http2Stub(mockedHttp2Responses); + + const client = new AuthorizedHttp2Client(mockApp); + http2SessionHandler = new Http2SessionHandler(mockHostUrl); + + const request: Http2RequestConfig = { + method: 'POST', + url: mockUrl, + headers: { + 'My-Custom-Header': 'CustomValue', + }, + data: reqData, + http2SessionHandler: http2SessionHandler, + }; + const requestCopy = deepCopy(request); + + return client.send(request).then((resp) => { + expect(http2Mocker.requests.length).to.equal(1); + expect(http2Mocker.requests[0].headers[':method']).to.equal('POST'); + expect(http2Mocker.requests[0].headers[':scheme']).to.equal('https:'); + expect(http2Mocker.requests[0].headers[':path']).to.equal(mockPath); + expect(JSON.parse(http2Mocker.requests[0].data)).to.deep.equal(reqData); + expect(http2Mocker.requests[0].headers['content-type']).to.contain('application/json'); + expect(http2Mocker.requests[0].headers['My-Custom-Header']).to.equal('CustomValue'); + expect(http2Mocker.requests[0].headers['Authorization']).to.contain('access_token_'); + expect(resp.status).to.equal(200); + expect(request).to.deep.equal(requestCopy); + }); + }); +}); + describe('ApiSettings', () => { describe('Constructor', () => { it('should succeed with a specified endpoint and a default http method', () => {