diff --git a/src/messaging/messaging-api-request-internal.ts b/src/messaging/messaging-api-request-internal.ts index 3c4e124d44..c7bcbc20eb 100644 --- a/src/messaging/messaging-api-request-internal.ts +++ b/src/messaging/messaging-api-request-internal.ts @@ -152,6 +152,43 @@ export class FirebaseMessagingRequestHandler { }); } + /** + * Invokes the HTTP/2 request handler for generic operations (e.g., topic subscriptions). + * + * @param host - The host to which to send the request. + * @param path - The path to which to send the request. + * @param method - The HTTP method to use. + * @param requestData - Optional request data. + * @param http2SessionHandler - The HTTP/2 session handler. + * @returns A promise that resolves with the response data. + */ + public invokeHttp2RequestHandler( + host: string, + path: string, + method: HttpMethod, + requestData: object | undefined, + http2SessionHandler: Http2SessionHandler + ): Promise { + const request: Http2RequestConfig = { + method, + url: `https://${host}${path}`, + data: requestData, + headers: FIREBASE_MESSAGING_HEADERS, + timeout: FIREBASE_MESSAGING_TIMEOUT, + http2SessionHandler, + }; + return this.http2Client.send(request).then((response) => { + if (!response.isJson()) { + throw new RequestResponseError(response); + } + const errorCode = getErrorCode(response.data); + if (errorCode) { + throw new RequestResponseError(response); + } + return response.data; + }); + } + private buildSendResponse(response: RequestResponse): SendResponse { const result: SendResponse = { success: response.status === 200, diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 05367128f4..542646699f 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -23,6 +23,7 @@ import { import * as utils from '../utils'; import * as validator from '../utils/validator'; import { validateMessage } from './messaging-internal'; +import { getErrorCode, createFirebaseError } from './messaging-errors-internal'; import { FirebaseMessagingRequestHandler } from './messaging-api-request-internal'; import { @@ -34,53 +35,14 @@ import { // Legacy API types SendResponse, } from './messaging-api'; -import { Http2SessionHandler } from '../utils/api-request'; +import { Http2SessionHandler, RequestResponseError } from '../utils/api-request'; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; -const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; // Maximum messages that can be included in a batch request. const FCM_MAX_BATCH_SIZE = 500; -/** - * 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. - */ -function mapRawResponseToTopicManagementResponse(response: object): MessagingTopicManagementResponse { - // Add the success and failure counts. - const result: MessagingTopicManagementResponse = { - successCount: 0, - failureCount: 0, - errors: [], - }; - - if ('results' in response) { - (response as any).results.forEach((tokenManagementResult: any, index: number) => { - // Map the FCM server's error strings to actual error objects. - if ('error' in tokenManagementResult) { - result.failureCount += 1; - const newError = FirebaseMessagingError.fromTopicManagementServerError( - tokenManagementResult.error, /* message */ undefined, tokenManagementResult.error, - ); - - result.errors.push({ - index, - error: newError, - }); - } else { - result.successCount += 1; - } - }); - } - return result; -} - /** * Messaging service bound to the provided app. @@ -344,7 +306,7 @@ export class Messaging { registrationTokenOrTokens, topic, 'subscribeToTopic', - FCM_TOPIC_MANAGEMENT_ADD_PATH, + '', ); } @@ -371,7 +333,7 @@ export class Messaging { registrationTokenOrTokens, topic, 'unsubscribeFromTopic', - FCM_TOPIC_MANAGEMENT_REMOVE_PATH, + '', ); } @@ -413,7 +375,7 @@ export class Messaging { registrationTokenOrTokens: string | string[], topic: string, methodName: string, - path: string, + _path: string, ): Promise { this.validateRegistrationTokensType(registrationTokenOrTokens, methodName); this.validateTopicType(topic, methodName); @@ -434,17 +396,88 @@ export class Messaging { registrationTokensArray = [registrationTokenOrTokens as string]; } - const request = { - to: topic, - registration_tokens: registrationTokensArray, - }; + return utils.findProjectId(this.app).then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseMessagingError( + MessagingClientErrorCode.INVALID_ARGUMENT, + 'Failed to determine project ID for Messaging. Initialize the ' + + 'SDK with service account credentials or set project ID as an app option. ' + + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', + ); + } - return this.messagingRequestHandler.invokeRequestHandler( - FCM_TOPIC_MANAGEMENT_HOST, path, request, - ); - }) - .then((response) => { - return mapRawResponseToTopicManagementResponse(response); + const topicName = topic.replace(/^\/topics\//, ''); + const isSubscribe = methodName === 'subscribeToTopic'; + const httpMethod = isSubscribe ? 'POST' : 'DELETE'; + + const http2SessionHandler = new Http2SessionHandler('https://fcm.googleapis.com'); + + let settledPromise: Promise[]>; + return new Promise((resolve, reject) => { + http2SessionHandler.invoke().catch((error) => { + reject(new FirebaseMessagingSessionError(error, undefined, undefined)); + }); + + const requests = registrationTokensArray.map(async (registrationId) => { + let requestPath = `/v1/projects/${projectId}/registrations/${registrationId}/topicSubscriptions`; + if (isSubscribe) { + requestPath += `?topic_name=${topicName}`; + } else { + requestPath += `/${topicName}?allow_missing=true`; + } + return this.messagingRequestHandler.invokeHttp2RequestHandler( + 'fcm.googleapis.com', + requestPath, + httpMethod, + isSubscribe ? {} : undefined, + http2SessionHandler + ); + }); + + settledPromise = Promise.allSettled(requests); + settledPromise.then((results) => { + if (results.length > 0 && results.every((r) => r.status === 'rejected')) { + const firstReason = (results[0] as PromiseRejectedResult).reason; + if (firstReason instanceof RequestResponseError) { + reject(createFirebaseError(firstReason)); + } else { + reject(firstReason); + } + return; + } + + const response: MessagingTopicManagementResponse = { + successCount: 0, + failureCount: 0, + errors: [], + }; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + response.successCount += 1; + } else { + response.failureCount += 1; + const err = result.reason; + const errorCode = err.response?.isJson() ? getErrorCode(err.response.data) : null; + const errorMessage = err.response?.isJson() ? err.response.data?.error?.message : err.message; + const newError = FirebaseMessagingError.fromTopicManagementServerError( + errorCode || 'UNKNOWN', + errorMessage, + err.response?.isJson() ? err.response.data : undefined + ); + response.errors.push({ + index, + error: newError, + }); + } + }); + + resolve(response); + }); + }).finally(() => { + http2SessionHandler.close(); + }); + }); }); } diff --git a/test/unit/messaging/messaging.spec.ts b/test/unit/messaging/messaging.spec.ts index af4dad1d4d..dbee4fb373 100644 --- a/test/unit/messaging/messaging.spec.ts +++ b/test/unit/messaging/messaging.spec.ts @@ -44,9 +44,6 @@ const expect = chai.expect; // FCM endpoints const FCM_SEND_HOST = 'fcm.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com'; -const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd'; -const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove'; const mockServerErrorResponse = { json: { @@ -152,57 +149,10 @@ function mockErrorResponse( }); } -function mockTopicSubscriptionRequest( - methodName: string, - successCount = 1, - failureCount = 0, -): nock.Scope { - const mockedResults = []; - - for (let i = 0; i < successCount; i++) { - mockedResults.push({}); - } - - for (let i = 0; i < failureCount; i++) { - mockedResults.push({ error: 'TOO_MANY_TOPICS' }); - } - - const path = (methodName === 'subscribeToTopic') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; - - return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) - .post(path) - .reply(200, { - results: mockedResults, - }); -} - -function mockTopicSubscriptionRequestWithError( - methodName: string, - statusCode: number, - errorFormat: 'json' | 'text', - responseOverride?: any, -): nock.Scope { - let response; - let contentType; - if (errorFormat === 'json') { - response = mockServerErrorResponse.json; - contentType = 'application/json; charset=UTF-8'; - } else { - response = mockServerErrorResponse.text; - contentType = 'text/html; charset=UTF-8'; - } - - const path = (methodName === 'subscribeToTopic') ? FCM_TOPIC_MANAGEMENT_ADD_PATH : FCM_TOPIC_MANAGEMENT_REMOVE_PATH; - - return nock(`https://${FCM_TOPIC_MANAGEMENT_HOST}:443`) - .post(path) - .reply(statusCode, responseOverride || response, { - 'Content-Type': contentType, - }); -} function disableRetries(messaging: Messaging): void { (messaging as any).messagingRequestHandler.httpClient.retry = null; + (messaging as any).messagingRequestHandler.http2Client.retry = null; } class CustomArray extends Array { } @@ -228,7 +178,7 @@ describe('Messaging', () => { 'X-Goog-Api-Client': getMetricsHeader(), 'access_token_auth': 'true', }; - const emptyResponse = utils.responseFrom({}); + after(() => { nock.cleanAll(); @@ -257,6 +207,62 @@ describe('Messaging', () => { return mockApp.delete(); }); + function mockTopicSubscriptionRequest( + methodName: string, + successCount = 1, + failureCount = 0, + ): nock.Scope { + for (let i = 0; i < successCount; i++) { + mockedHttp2Responses.push({ + headers: { ':status': 200, 'content-type': 'application/json; charset=UTF-8' }, + data: Buffer.from(JSON.stringify({})), + } as any); + } + + for (let i = 0; i < failureCount; i++) { + mockedHttp2Responses.push({ + headers: { ':status': 400, 'content-type': 'application/json; charset=UTF-8' }, + data: Buffer.from(JSON.stringify({ error: { status: 'TOO_MANY_TOPICS', message: 'TOO_MANY_TOPICS' } })), + } as any); + } + + http2Mocker.http2Stub(mockedHttp2Responses); + return { done: () => {} } as any; + } + + function mockTopicSubscriptionRequestWithError( + methodName: string, + statusCode: number, + errorFormat: 'json' | 'text', + responseOverride?: any, + ): nock.Scope { + let responseStr; + let contentType; + if (errorFormat === 'json') { + const overrideObj = responseOverride || mockServerErrorResponse.json; + let respObj = overrideObj; + if (overrideObj && overrideObj.error && typeof overrideObj.error === 'string') { + respObj = { error: { status: overrideObj.error, message: overrideObj.error } }; + } else if (overrideObj && typeof overrideObj.error === 'object') { + respObj = overrideObj; + } else if (overrideObj && overrideObj.foo) { + respObj = overrideObj; + } + responseStr = JSON.stringify(respObj); + contentType = 'application/json; charset=UTF-8'; + } else { + responseStr = responseOverride || mockServerErrorResponse.text; + contentType = 'text/html; charset=UTF-8'; + } + + mockedHttp2Responses.push({ + headers: { ':status': statusCode, 'content-type': contentType }, + data: Buffer.from(responseStr), + } as any); + + http2Mocker.http2Stub(mockedHttp2Responses); + return { done: () => {} } as any; + } describe('Constructor', () => { const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; @@ -2899,44 +2905,34 @@ describe('Messaging', () => { it('should set the appropriate request data given a single registration token and topic ' + '(topic name not prefixed with "/topics/")', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topic, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: [mocks.messaging.registrationToken], - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); - }); + mockTopicSubscriptionRequest(methodName, 1); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topic, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(1); + const req = http2Mocker.requests[0]; + const isSub = methodName === 'subscribeToTopic'; + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + expect(req.headers[':path']).to.contain(expectedSuffix); + }); }); it('should set the appropriate request data given a single registration token and topic ' + '(topic name prefixed with "/topics/")', () => { - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - mocks.messaging.registrationToken, - mocks.messaging.topicWithPrefix, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: [mocks.messaging.registrationToken], - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); - }); + mockTopicSubscriptionRequest(methodName, 1); + return messagingService[methodName]( + mocks.messaging.registrationToken, + mocks.messaging.topicWithPrefix, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(1); + const req = http2Mocker.requests[0]; + const isSub = methodName === 'subscribeToTopic'; + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + expect(req.headers[':path']).to.contain(expectedSuffix); + }); }); it('should set the appropriate request data given an array of registration tokens and ' + @@ -2947,23 +2943,20 @@ describe('Messaging', () => { mocks.messaging.registrationToken + '2', ]; - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - registrationTokens, - mocks.messaging.topic, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: registrationTokens, - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); + mockTopicSubscriptionRequest(methodName, 3); + return messagingService[methodName]( + registrationTokens, + mocks.messaging.topic, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(3); + const isSub = methodName === 'subscribeToTopic'; + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + http2Mocker.requests.forEach((req, idx) => { + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + expect(req.headers[':path']).to.contain(registrationTokens[idx]); + expect(req.headers[':path']).to.contain(expectedSuffix); }); + }); }); it('should set the appropriate request data given an array of registration tokens and ' + @@ -2974,23 +2967,20 @@ describe('Messaging', () => { mocks.messaging.registrationToken + '2', ]; - // Wait for the initial getToken() call to complete before stubbing https.request. - return mockApp.INTERNAL.getToken() - .then(() => { - httpsRequestStub = sinon.stub(HttpClient.prototype, 'send').resolves(emptyResponse); - return messagingService[methodName]( - registrationTokens, - mocks.messaging.topicWithPrefix, - ); - }) - .then(() => { - const expectedReq = { - to: mocks.messaging.topicWithPrefix, - registration_tokens: registrationTokens, - }; - expect(httpsRequestStub).to.have.been.calledOnce; - expect(httpsRequestStub.args[0][0].data).to.deep.equal(expectedReq); + mockTopicSubscriptionRequest(methodName, 3); + return messagingService[methodName]( + registrationTokens, + mocks.messaging.topicWithPrefix, + ).then(() => { + expect(http2Mocker.requests.length).to.equal(3); + const isSub = methodName === 'subscribeToTopic'; + const expectedSuffix = isSub ? '?topic_name=mock-topic' : '/mock-topic?allow_missing=true'; + http2Mocker.requests.forEach((req, idx) => { + expect(req.headers[':method']).to.equal(isSub ? 'POST' : 'DELETE'); + expect(req.headers[':path']).to.contain(registrationTokens[idx]); + expect(req.headers[':path']).to.contain(expectedSuffix); }); + }); }); }