Azure / azure-sdk

This is the Azure SDK parent repository and mostly contains documentation around guidelines and policies as well as the releases for the various languages supported by the Azure SDK.
http://azure.github.io/azure-sdk
MIT License
487 stars 296 forks source link

Board Review: Introducing Azure Notification Hubs (JS) #4489

Closed mpodwysocki closed 1 year ago

mpodwysocki commented 2 years ago

Thank you for starting the process for approval of the client library for your Azure service. Thorough review of your client library ensures that your APIs are consistent with the guidelines and the consumers of your client library have a consistently good experience when using Azure.

The Architecture Board reviews Track 2 libraries only. If your library does not meet this requirement, please reach out to Architecture Board before creating the issue.

Please reference our review process guidelines to understand what is being asked for in the issue template.

Before submitting, ensure you adjust the title of the issue appropriately.

Note that the required material must be included before a meeting can be scheduled.

Contacts and Timeline

About the Service

About the client library

Step 1: Champion Scenarios

Azure Notification Hubs provide a scaled-out push engine that enables you to send notifications to any platform (Apple, Amazon Kindle, Android, Baidu, Web, Windows, etc.) from any back-end (cloud or on-premises). Notification Hubs works great for both enterprise and consumer scenarios. Here are a few example scenarios:

The following are the champion scenarios:

Champion Scenario 1 - Authentication and Initialization

Azure Notification Hubs currently only supports Shared Access Signature to authenticate clients to the service. This includes the following permission levels: Listen, Manage, Send.

Listen allows for a client to register itself via the Registration and Installations API. Send allows for the client to send notifications to devices using the send* APIs. Finally, Manage allows the user to do Registration and Installation management, such as queries.

import { clientFromConnectionString } from "@azure/notification-hubs";

const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

const client = clientFromConnectionString(connectionString, hubName);

Champion Scenario 2 - Create an Installation

An Installation is a more modern way of expressing a device and its associated metadata in Notification Hubs. This is expressed natively in JSON instead of the Atom+XML of the Registration APIs and adds additional features such as an Installation ID and User ID which can be used for targeting a device. In addition, it also supports templates built into the installation itself instead of being separate in the Registration APIs.

Each device type has its own factory method to create an installation:

NOTE: Notification Hubs does not support the V1 Firebase API.

const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";

// client code initialization copied from above

const installation = createAppleInstallation({
  installationId: v4(),
  pushChannel: DUMMY_DEVICE,
  tags: ["likes_hockey", "likes_football"],
});

const updatedInstallation = await client.createOrUpdateInstallation(installation);

Update an Existing Installation via Patch

An installation can be updated in two ways, via a createOrUupdateInstallation method which overwrites the existing installation, or using updateInstallation which uses the JSON Patch RFC6902 Specification. In this instance, we will use the PATCH semantics to update an installation.

// client code initialization copied from above

const updates: JsonPatch[] = [
  { op: "add", path: "/tags", value: "likes_baseball" },
  { op: "add", path: "/userId", value: "bob@contoso.com" },
];

const updatedInstallation = await client.updateInstallation(installationId, updates);

Create or Update a Registration

The Registrations API is also supported using this library. Support for all platforms and templates are also supported.

To create a registration, you use the appropriate factory method per registration type for regular or template registrations.

NOTE: There are classes and factory methods for Google Cloud Messaging (GCM) and Windows Phone (MPNS) which are both deprecated and should only be used for registration list operations.

const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";

const registrationId = await client.createRegistrationId();

const registration = createAppleRegistrationDescription({
  registrationId,
  deviceToken : DUMMY_TOKEN,
  tags: ["likes_football", "likes_hockey"],
});

const registrationResponse = await client.createOrUpdateRegistration(registration);

Query Registrations

Registrations be queried either by listing all registrations which supports OData queries for $filter and $top with listRegistrations or by tag via the listRegistrationsByTag method. For example we can query for those that have the APNS device token of the following:

// Define message constants
const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";
const devicetoken = process.env.APNS_DEVICE_TOKEN || DUMMY_DEVICE;
const FILTER = `DeviceToken eq "${devicetoken}"`;
const TOP = 100;

let page = 0;

const allRegistrations = client.listRegistrations({ top: TOP, filter: FILTER });
for await (const pages of allRegistrations.byPage()) {
  console.log(`Page number ${page}`);
  for (const item of pages) {
    console.log(JSON.stringify(item, null, 2));
  }
}

Send a Direct Notification

Notification Hubs can be used to send notifications to Apple, Amazon, Android, Browsers via Web Push and Windows devices. Notifications can be sent to individual devices using Direct Send when given the device unique ID from the Platform Notification Service (PNS). These messages contain a body, headers to send to the PNS, content-type and platform type.

For debugging purposes, Enable Test Send is exposed via the options.

Support for creating messages is via the factory methods:

NOTE In the future, we are considering message builders which will be more opinionated in creating messages for each platform with specific features such as Apple's, Firebase's, or Windows' message formats in future beta releases.

const messageBody = `{ "aps" : { "alert" : "Hello" } }`;

const message = createAppleMessage({
  body: messageBody,
  headers: {
    "apns-priority": "10",
    "apns-push-type": "alert",
  },
});

// Not required but can set test send to true for debugging purposes.
const sendOptions: SendOperationOptions = { enableTestSend: false };
const result = await client.sendDirectNotification(devicetoken, message, sendOptions);

Send Notification to an Audience

In addition to supporting sending to a specific device, you can use tags to enrich your device for targeting. Via the sendNotification method, you can send a list of tags which then does a "Logical Or" of the tags, or you can specify a tag expression using && and || for conditional checks.

const messageBody = `{ "aps" : { "alert" : "Hello" } }`;
const tagExpression = "likes_hockey && likes_football";

const message = createAppleMessage({
  body: messageBody,
  headers: {
    "apns-priority": "10",
    "apns-push-type": "alert",
  },
});

// Not required but can set test send to true for debugging purposes.
const sendOptions: SendOperationOptions = { enableTestSend: false };
const result = await client.sendNotification(tagExpression, message, sendOptions);

Get Notification Details from a Notification

If the developer is using the Standard SKU and above, they can get detailed results of each notification. The notification ID is sent back as a location header which is then parsed. This notification ID can then be used to query the backend using the getNotificationOutcomeDetails method. Currently this is an operation that could support long polling but for early beta does not. Detailed information can be found in the REST API Get Notification Telemetry Details

const result = await client.sendDirectNotification(devicetoken, message, sendOptions);
// Only available in Standard SKU and above
if (result.notificationId) {
  console.log(`Direct send Notification ID: ${result.notificationId}`);

  const results = await getNotificationDetails(client, result.notificationId);
  if (results) {
    console.log(JSON.stringify(results, null, 2));
  }
}

async function getNotificationDetails(
  client: NotificationHubsClient,
  notificationId: string
): Promise<NotificationDetails | undefined> {
  let state: NotificationOutcomeState = "Enqueued";
  let count = 0;
  let result: NotificationDetails | undefined;
  while ((state === "Enqueued" || state === "Processing") && count++ < 10) {
    try {
      result = await client.getNotificationOutcomeDetails(notificationId);
      state = result.state!;
    } catch (e) {
      // Possible to get 404 for when it doesn't exist yet.
    }

    await delay(1000);
  }

  return result;
}

Step 2: Quickstart Samples (Optional)

Each code snippet is also found in the samples-dev but is also here for posterity.

Create Installation Full Code

import { clientFromConnectionString, createAppleInstallation } from "@azure/notification-hubs";
import { v4 } from "uuid";

// Load the .env file if it exists
import * as dotenv from "dotenv";
dotenv.config();

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

// Define message constants
const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";
const deviceToken = process.env.APNS_DEVICE_TOKEN || DUMMY_DEVICE;

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  const installation = createAppleInstallation({
    installationId: v4(),
    pushChannel: deviceToken,
    tags: ["likes_hockey", "likes_football"],
  });

  const updatedInstallation = await client.createOrUpdateInstallation(installation);
  console.log(`Installation last update: ${updatedInstallation.lastUpdate}`);
}

main().catch((err) => {
  console.log("createInstallation Sample: Error occurred: ", err);
  process.exit(1);
});

Update An Installation

import { clientFromConnectionString, JsonPatch } from "@azure/notification-hubs";

// Load the .env file if it exists
import * as dotenv from "dotenv";
dotenv.config();

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

// Define an existing Installation ID.
const installationId = process.env.INSTALLATION_ID || "<installation id>";

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  const updates: JsonPatch[] = [
    { op: "add", path: "/tags", value: "likes_baseball" },
    { op: "add", path: "/userId", value: "bob@contoso.com" },
  ];

  const updatedInstallation = await client.updateInstallation(installationId, updates);
  console.log(`Installation last update: ${updatedInstallation.lastUpdate}`);
}

main().catch((err) => {
  console.log("updateInstallation Sample: Error occurred: ", err);
  process.exit(1);
});

Create or Update a Registration

import {
  createAppleRegistrationDescription,
  clientFromConnectionString,
} from "@azure/notification-hubs";

// Load the .env file if it exists
import * as dotenv from "dotenv";
dotenv.config();

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

// Define message constants
const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";
const deviceToken = process.env.APNS_DEVICE_TOKEN || DUMMY_DEVICE;

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  const registrationId = await client.createRegistrationId();

  const registration = createAppleRegistrationDescription({
    registrationId,
    deviceToken,
    tags: ["likes_football", "likes_hockey"],
  });

  const registrationResponse = await client.createOrUpdateRegistration(registration);

  console.log(`Registration ID: ${registrationResponse.registrationId}`);
}

main().catch((err) => {
  console.log("createRegistration Sample: Error occurred: ", err);
  process.exit(1);
});

Send a Direct Notification and Get Notification Details

import {
  createAppleMessage,
  clientFromConnectionString,
  SendOperationOptions,
  NotificationDetails,
  NotificationOutcomeState,
  NotificationHubsClient,
} from "@azure/notification-hubs";
import { delay } from "@azure/core-amqp";

// Load the .env file if it exists
import * as dotenv from "dotenv";
dotenv.config();

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

// Define message constants
const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";
const devicetoken = process.env.APNS_DEVICE_TOKEN || DUMMY_DEVICE;

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  const messageBody = `{ "aps" : { "alert" : "Hello" } }`;

  const message = createAppleMessage({
    body: messageBody,
    headers: {
      "apns-priority": "10",
      "apns-push-type": "alert",
    },
  });

  // Not required but can set test send to true for debugging purposes.
  const sendOptions: SendOperationOptions = { enableTestSend: false };
  const result = await client.sendDirectNotification(devicetoken, message, sendOptions);

  console.log(`Direct send Tracking ID: ${result.trackingId}`);
  console.log(`Direct send Correlation ID: ${result.correlationId}`);

  // Only available in Standard SKU and above
  if (result.notificationId) {
    console.log(`Direct send Notification ID: ${result.notificationId}`);

    const results = await getNotificationDetails(client, result.notificationId);
    if (results) {
      console.log(JSON.stringify(results, null, 2));
    }
  }
}

async function getNotificationDetails(
  client: NotificationHubsClient,
  notificationId: string
): Promise<NotificationDetails | undefined> {
  let state: NotificationOutcomeState = "Enqueued";
  let count = 0;
  let result: NotificationDetails | undefined;
  while ((state === "Enqueued" || state === "Processing") && count++ < 10) {
    try {
      result = await client.getNotificationOutcomeDetails(notificationId);
      state = result.state!;
    } catch (e) {
      // Possible to get 404 for when it doesn't exist yet.
    }

    await delay(1000);
  }

  return result;
}

main().catch((err) => {
  console.log("sendDirectNotification Sample: Error occurred: ", err);
  process.exit(1);
});

Send Notification to an Audience and Get Notification Details

import {
  createAppleMessage,
  clientFromConnectionString,
  SendOperationOptions,
  NotificationDetails,
  NotificationHubsClient,
  NotificationOutcomeState,
} from "@azure/notification-hubs";
import { delay } from "@azure/core-amqp";

// Load the .env file if it exists
import * as dotenv from "dotenv";
dotenv.config();

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  const messageBody = `{ "aps" : { "alert" : "Hello" } }`;
  const tagExpression = "likes_hockey && likes_football";

  const message = createAppleMessage({
    body: messageBody,
    headers: {
      "apns-priority": "10",
      "apns-push-type": "alert",
    },
  });

  // Not required but can set test send to true for debugging purposes.
  const sendOptions: SendOperationOptions = { enableTestSend: false };
  const result = await client.sendNotification(tagExpression, message, sendOptions);

  console.log(`Tag Expression send Tracking ID: ${result.trackingId}`);
  console.log(`Tag Expression Correlation ID: ${result.correlationId}`);

  // Only available in Standard SKU and above
  if (result.notificationId) {
    console.log(`Direct send Notification ID: ${result.notificationId}`);

    const results = await getNotificationDetails(client, result.notificationId);
    if (results) {
      console.log(JSON.stringify(results, null, 2));
    }
  }
}

async function getNotificationDetails(
  client: NotificationHubsClient,
  notificationId: string
): Promise<NotificationDetails | undefined> {
  let state: NotificationOutcomeState = "Enqueued";
  let count = 0;
  let result: NotificationDetails | undefined;
  while ((state === "Enqueued" || state === "Processing") && count++ < 10) {
    try {
      result = await client.getNotificationOutcomeDetails(notificationId);
      state = result.state!;
    } catch (e) {
      // Possible to get 404 for when it doesn't exist yet.
    }

    await delay(1000);
  }

  return result;
}

main().catch((err) => {
  console.log("sendTagExpression Sample: Error occurred: ", err);
  process.exit(1);
});

List Registrations

import { clientFromConnectionString } from "@azure/notification-hubs";

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

// Define message constants
const DUMMY_DEVICE = "00fc13adff785122b4ad28809a3420982341241421348097878e577c991de8f0";
const devicetoken = process.env.APNS_DEVICE_TOKEN || DUMMY_DEVICE;
const FILTER = `DeviceToken eq "${devicetoken}"`;

const TOP = 100;

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  // Unlimited
  let allRegistrations = client.listRegistrations();
  let page = 0;
  for await (const pages of allRegistrations.byPage()) {
    console.log(`Page number ${page}`);
    for (const item of pages) {
      console.log(JSON.stringify(item, null, 2));
    }
  }

  // Top
  page = 0;
  allRegistrations = client.listRegistrations({ top: TOP });
  for await (const pages of allRegistrations.byPage()) {
    console.log(`Page number ${page}`);
    for (const item of pages) {
      console.log(JSON.stringify(item, null, 2));
    }
  }

  // Query and Top
  page = 0;

  allRegistrations = client.listRegistrations({ top: TOP, filter: FILTER });
  for await (const pages of allRegistrations.byPage()) {
    console.log(`Page number ${page}`);
    for (const item of pages) {
      console.log(JSON.stringify(item, null, 2));
    }
  }
}

main().catch((err) => {
  console.log("listRegistrations Sample: Error occurred: ", err);
  process.exit(1);
});

List Registrations by Tag

import { clientFromConnectionString } from "@azure/notification-hubs";

// Define connection string and hub name
const connectionString = process.env.NOTIFICATIONHUBS_CONNECTION_STRING || "<connection string>";
const hubName = process.env.NOTIFICATION_HUB_NAME || "<hub name>";

const TOP = 100;
const TAG = "likes_hockey";

async function main() {
  const client = clientFromConnectionString(connectionString, hubName);

  // Unlimited
  let allRegistrations = client.listRegistrationsByTag(TAG);
  let page = 0;
  for await (const pages of allRegistrations.byPage()) {
    console.log(`Page number ${page}`);
    for (const item of pages) {
      console.log(JSON.stringify(item, null, 2));
    }
  }

  // Top
  page = 0;
  allRegistrations = client.listRegistrationsByTag(TAG, { top: TOP });
  for await (const pages of allRegistrations.byPage()) {
    console.log(`Page number ${page}`);
    for (const item of pages) {
      console.log(JSON.stringify(item, null, 2));
    }
  }
}

main().catch((err) => {
  console.log("sendDirectNotification Sample: Error occurred: ", err);
  process.exit(1);
});

Thank you for your submission!

kyle-patterson commented 2 years ago

Scheduled 7/13 2p-4p PDT

webmaxru commented 2 years ago

Great work, @mpodwysocki!

Just one thing to discuss: for me, Web Push API is more related to «Web App» rather to a «Browser» (which is just an entry point for user experience, and hopefully we’ll have browserless installations of PWAs in the future - like enterprise usecase for the managed devices). After the webapp installation, there is no longer [visible] browser involved.

To sum up: do you consider other than «browser» prefix for API namings for Web Push scenario?

Disclaimer: I’m not a Board member, just a Web Push / PWA enthusiast from Microsoft :)

webmaxru commented 2 years ago

On a technical topic: it would be cool to have a sample of «create installation» code for Web Push to make sure that it covers all API specifics.

Do I understand correctly that in that case a deviceToken is endpoint of the subscription object received after registration on the client and persisted in a separate (non NH-specific) storage by the developer?

tg-msft commented 2 years ago

Recording (MS INTERNAL ONLY)