Skip to content

Commit

Permalink
feat(fcm): Add sendEach and sendEachForMulticast for FCM batch se…
Browse files Browse the repository at this point in the history
…nd (#2138)

* Deprecate sendAll and sendMulticast (#2094)

1. Deprecate sendAll and sendMulticast
2. Add dummy implementation for sendEach and sendEachForMulticast to avoid errors reported by api-extractor

* Implement `sendEach` and `sendEachForMulticast` (#2097)

`sendEach` vs `sendAll`
1. `sendEach` sends one HTTP request to V1 Send endpoint for each
    message in the array.
   `sendAll` sends only one HTTP request to V1 Batch Send endpoint
    to send all messages in the array.
2. `sendEach` calls `Promise.allSettled` to wait for all
   `httpClient.send` calls to complete and construct a `BatchResponse`.
    An `httpClient.send` call to V1 Send endpoint either completes
    with a success or throws an error. So if an error is thrown out,
    the error will be caught in `sendEach` and turned into a
    `SendResponse` with an error.
    Therefore, unlike `sendAll`, `sendEach` does not always throw
    an error for a total failure. It can also return a `BatchResponse`
    with only errors in it.

`sendEachForMulticast` calls `sendEach` under the hood.

* Add integration tests for `sendEach` and `sendMulticast` (#2130)

* Avoid using "-- i.e." in the function comment
  • Loading branch information
Doris-Ge committed Apr 12, 2023
1 parent 90426de commit 0da72ef
Show file tree
Hide file tree
Showing 5 changed files with 747 additions and 15 deletions.
4 changes: 4 additions & 0 deletions etc/firebase-admin.messaging.api.md
Expand Up @@ -184,7 +184,11 @@ export type Message = TokenMessage | TopicMessage | ConditionMessage;
export class Messaging {
get app(): App;
send(message: Message, dryRun?: boolean): Promise<string>;
// @deprecated
sendAll(messages: Message[], dryRun?: boolean): Promise<BatchResponse>;
sendEach(messages: Message[], dryRun?: boolean): Promise<BatchResponse>;
sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse>;
// @deprecated
sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse>;
sendToCondition(condition: string, payload: MessagingPayload, options?: MessagingOptions): Promise<MessagingConditionResponse>;
// @deprecated
Expand Down
35 changes: 35 additions & 0 deletions src/messaging/messaging-api-request-internal.ts
Expand Up @@ -96,6 +96,34 @@ export class FirebaseMessagingRequestHandler {
});
}

/**
* Invokes the 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<SendResponse> {
const request: HttpRequestConfig = {
method: FIREBASE_MESSAGING_HTTP_METHOD,
url: `https://${host}${path}`,
data: requestData,
headers: LEGACY_FIREBASE_MESSAGING_HEADERS,
timeout: FIREBASE_MESSAGING_TIMEOUT,
};
return this.httpClient.send(request).then((response) => {
return this.buildSendResponse(response);
})
.catch((err) => {
if (err instanceof HttpError) {
return this.buildSendResponseFromError(err);
}
// Re-throw the error if it already has the proper format.
throw err;
});
}

/**
* Sends the given array of sub requests as a single batch to FCM, and parses the result into
* a BatchResponse object.
Expand Down Expand Up @@ -136,4 +164,11 @@ export class FirebaseMessagingRequestHandler {
}
return result;
}

private buildSendResponseFromError(err: HttpError): SendResponse {
return {
success: false,
error: createFirebaseError(err)
};
}
}
133 changes: 128 additions & 5 deletions src/messaging/messaging.ts
Expand Up @@ -39,6 +39,7 @@ import {
MessagingConditionResponse,
DataMessagePayload,
NotificationMessagePayload,
SendResponse,
} from './messaging-api';

// FCM endpoints
Expand Down Expand Up @@ -250,6 +251,124 @@ export class Messaging {
});
}

/**
* Sends each message in the given array via Firebase Cloud Messaging.
*
* Unlike {@link Messaging.sendAll}, this method makes a single RPC call for each message
* in the given array.
*
* The responses list obtained from the return value corresponds to the order of `messages`.
* An error from this method or a `BatchResponse` with all failures indicates a total failure,
* meaning that none of the messages in the list could be sent. Partial failures or no
* failures are only indicated by a `BatchResponse` return value.
*
* @param messages - A non-empty array
* containing up to 500 messages.
* @param dryRun - Whether to send the messages in the dry-run
* (validation only) mode.
* @returns A Promise fulfilled with an object representing the result of the
* send operation.
*/
public sendEach(messages: Message[], dryRun?: boolean): Promise<BatchResponse> {
if (validator.isArray(messages) && messages.constructor !== Array) {
// In more recent JS specs, an array-like object might have a constructor that is not of
// Array type. Our deepCopy() method doesn't handle them properly. Convert such objects to
// a regular array here before calling deepCopy(). See issue #566 for details.
messages = Array.from(messages);
}

const copy: Message[] = deepCopy(messages);
if (!validator.isNonEmptyArray(copy)) {
throw new FirebaseMessagingError(
MessagingClientErrorCode.INVALID_ARGUMENT, 'messages must be a non-empty array');
}
if (copy.length > FCM_MAX_BATCH_SIZE) {
throw new FirebaseMessagingError(
MessagingClientErrorCode.INVALID_ARGUMENT,
`messages list must not contain more than ${FCM_MAX_BATCH_SIZE} items`);
}
if (typeof dryRun !== 'undefined' && !validator.isBoolean(dryRun)) {
throw new FirebaseMessagingError(
MessagingClientErrorCode.INVALID_ARGUMENT, 'dryRun must be a boolean');
}

return this.getUrlPath()
.then((urlPath) => {
const requests: Promise<SendResponse>[] = copy.map((message) => {
validateMessage(message);
const request: { message: Message; validate_only?: boolean } = { message };
if (dryRun) {
request.validate_only = true;
}
return this.messagingRequestHandler.invokeRequestHandlerForSendResponse(FCM_SEND_HOST, urlPath, request);
});
return Promise.allSettled(requests);
}).then((results) => {
const responses: SendResponse[] = [];
results.forEach(result => {
if (result.status === 'fulfilled') {
responses.push(result.value);
} else { // rejected
responses.push({ success: false, error: result.reason })
}
})
const successCount: number = responses.filter((resp) => resp.success).length;
return {
responses,
successCount,
failureCount: responses.length - successCount,
};
});
}

/**
* Sends the given multicast message to all the FCM registration tokens
* specified in it.
*
* This method uses the {@link Messaging.sendEach} API under the hood to send the given
* message to all the target recipients. The responses list obtained from the
* return value corresponds to the order of tokens in the `MulticastMessage`.
* An error from this method or a `BatchResponse` with all failures indicates a total
* failure, meaning that the messages in the list could be sent. Partial failures or
* failures are only indicated by a `BatchResponse` return value.
*
* @param message - A multicast message
* containing up to 500 tokens.
* @param dryRun - Whether to send the message in the dry-run
* (validation only) mode.
* @returns A Promise fulfilled with an object representing the result of the
* send operation.
*/
public sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse> {
const copy: MulticastMessage = deepCopy(message);
if (!validator.isNonNullObject(copy)) {
throw new FirebaseMessagingError(
MessagingClientErrorCode.INVALID_ARGUMENT, 'MulticastMessage must be a non-null object');
}
if (!validator.isNonEmptyArray(copy.tokens)) {
throw new FirebaseMessagingError(
MessagingClientErrorCode.INVALID_ARGUMENT, 'tokens must be a non-empty array');
}
if (copy.tokens.length > FCM_MAX_BATCH_SIZE) {
throw new FirebaseMessagingError(
MessagingClientErrorCode.INVALID_ARGUMENT,
`tokens list must not contain more than ${FCM_MAX_BATCH_SIZE} items`);
}

const messages: Message[] = copy.tokens.map((token) => {
return {
token,
android: copy.android,
apns: copy.apns,
data: copy.data,
notification: copy.notification,
webpush: copy.webpush,
fcmOptions: copy.fcmOptions,
};
});
return this.sendEach(messages, dryRun);
}

/**
* Sends all the messages in the given array via Firebase Cloud Messaging.
* Employs batching to send the entire list as a single RPC call. Compared
Expand All @@ -258,8 +377,8 @@ export class Messaging {
*
* The responses list obtained from the return value
* corresponds to the order of tokens in the `MulticastMessage`. An error
* from this method indicates a total failure -- i.e. none of the messages in
* the list could be sent. Partial failures are indicated by a `BatchResponse`
* from this method indicates a total failure, meaning that none of the messages
* in the list could be sent. Partial failures are indicated by a `BatchResponse`
* return value.
*
* @param messages - A non-empty array
Expand All @@ -268,6 +387,8 @@ export class Messaging {
* (validation only) mode.
* @returns A Promise fulfilled with an object representing the result of the
* send operation.
*
* @deprecated Use {@link Messaging.sendEach} instead.
*/
public sendAll(messages: Message[], dryRun?: boolean): Promise<BatchResponse> {
if (validator.isArray(messages) && messages.constructor !== Array) {
Expand Down Expand Up @@ -316,16 +437,18 @@ export class Messaging {
* This method uses the `sendAll()` API under the hood to send the given
* message to all the target recipients. The responses list obtained from the
* return value corresponds to the order of tokens in the `MulticastMessage`.
* An error from this method indicates a total failure -- i.e. the message was
* not sent to any of the tokens in the list. Partial failures are indicated by
* a `BatchResponse` return value.
* An error from this method indicates a total failure, meaning that the message
* was not sent to any of the tokens in the list. Partial failures are indicated
* by a `BatchResponse` return value.
*
* @param message - A multicast message
* containing up to 500 tokens.
* @param dryRun - Whether to send the message in the dry-run
* (validation only) mode.
* @returns A Promise fulfilled with an object representing the result of the
* send operation.
*
* @deprecated Use {@link Messaging.sendEachForMulticast} instead.
*/
public sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse> {
const copy: MulticastMessage = deepCopy(message);
Expand Down
50 changes: 50 additions & 0 deletions test/integration/messaging.spec.ts
Expand Up @@ -108,6 +108,37 @@ describe('admin.messaging', () => {
});
});

it('sendEach()', () => {
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)', () => {
const messages: Message[] = [];
for (let i = 0; i < 500; i++) {
messages.push({ topic: `foo-bar-${i % 10}` });
}
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('sendAll()', () => {
const messages: Message[] = [message, message, message];
return getMessaging().sendAll(messages, true)
Expand Down Expand Up @@ -139,6 +170,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().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('sendMulticast()', () => {
const multicastMessage: MulticastMessage = {
data: message.data,
Expand Down

0 comments on commit 0da72ef

Please sign in to comment.