-
Notifications
You must be signed in to change notification settings - Fork 415
WIP feat(fcm): Migrate topic subscription APIs to new REST endpoints #3136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<MessagingTopicManagementResponse> { | ||
| 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<PromiseSettledResult<any>[]>; | ||
| return new Promise<MessagingTopicManagementResponse>((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(); | ||
| }); | ||
|
Comment on lines
+415
to
+479
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation uses the Refactoring this into a clean promise chain ensures that requests are only started after the session is successfully established and simplifies error handling. return http2SessionHandler.invoke()
.catch((error) => {
throw new FirebaseMessagingSessionError(error, undefined, undefined);
})
.then(() => {
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_SEND_HOST,
requestPath,
httpMethod,
isSubscribe ? {} : undefined,
http2SessionHandler
);
});
return Promise.allSettled(requests);
})
.then((results) => {
if (results.length > 0 && results.every((r) => r.status === 'rejected')) {
const firstReason = (results[0] as PromiseRejectedResult).reason;
if (firstReason instanceof RequestResponseError) {
throw createFirebaseError(firstReason);
} else {
throw firstReason;
}
}
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,
});
}
});
return response;
})
.finally(() => {
http2SessionHandler.close();
}); |
||
| }); | ||
| }); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid hardcoding the host string. Use the
FCM_SEND_HOSTconstant defined at the top of the file for consistency.