Apns output binding (#481)

* Create Apple Push Notification Output Binding

I created the apns package and created apns.go to implement the output
binding to send push notifications to Apple devices using the Apple Push
Notification Service.

* Refactor Code

I refactored and cleaned up the source code in apns.go. I made the code
easier to read and understand. I split out the JWT generation into
authorization_builder.go. I split out the create operation code into
create_operation.go and refactored it to follow a pipeline pattern.

I created doc.go and added documentation for how to use the APNS binding
to send push notifications to Apple devices.

* Fix Lint Issuer

I corrected issues reported by golangci-lint in the pull request. I ran
gofumpt against the test cases for the APNs binding. I fixed a naming
error in create_operation.go.

* Address Review Comment

I revised extractKeyID in apns.go to address a readability concern in
the code. I simplified the if/else block to make the logic easier to
understand.

* Add License Header to APNs Binding

I added the standard Microsoft copyright and MIT license header to the
source files for the APNs binding. This was requested from the PR
review.

* Revise APNS.extractTeamID for Readability

Per a PR comment, I revised the APNS.extractTeamID function to make the
function more readable.

* Revise APNS Create Operation

I revised the APNS create operation based on discussions in the PR
(#481). I deleted create_operation.go and merged the code back into the
main APNS type to simplify the code.

* Fix Linter Errors

I fixed linter errors reported in apns.go.

* Fix Code Review Comments

I switched from using encoding/json for JSON encoding and decoding to
using jsoniter.

I updated authorization_builder.go to define the expiration period as a
constant and provided a comment describing the need for and use of the
expiration period.

Co-authored-by: Yaron Schneider <yaronsc@microsoft.com>
Co-authored-by: Mark Chmarny <mchmarny@users.noreply.github.com>
Co-authored-by: Young Bu Park <youngp@microsoft.com>
This commit is contained in:
Michael Collins 2020-10-09 10:26:44 -07:00 committed by GitHub
parent 2c403ad238
commit b08e920cdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 822 additions and 0 deletions

243
bindings/apns/apns.go Normal file
View File

@ -0,0 +1,243 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// ------------------------------------------------------------
package apns
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"sync"
"github.com/dapr/components-contrib/bindings"
"github.com/dapr/dapr/pkg/logger"
jsoniter "github.com/json-iterator/go"
)
const (
collapseIDKey = "apns-collapse-id"
developmentKey = "development"
developmentPrefix = "https://api.sandbox.push.apple.com/3/device/"
deviceTokenKey = "device-token"
expirationKey = "apns-expiration"
keyIDKey = "key-id"
messageIDKey = "apns-id"
priorityKey = "apns-priority"
privateKeyKey = "private-key"
productionPrefix = "https://api.push.apple.com/3/device/"
pushTypeKey = "apns-push-type"
teamIDKey = "team-id"
topicKey = "apns-topic"
)
type notificationResponse struct {
MessageID string `json:"messageID"`
}
type errorResponse struct {
Reason string `json:"reason"`
Timestamp int64 `json:"timestamp"`
}
// APNS implements an outbound binding that allows services to send push
// notifications to Apple devices using Apple's Push Notification Service.
type APNS struct {
logger logger.Logger
client *http.Client
urlPrefix string
authorizationBuilder *authorizationBuilder
}
// NewAPNS will create a new APNS output binding.
func NewAPNS(logger logger.Logger) *APNS {
return &APNS{
logger: logger,
client: &http.Client{},
authorizationBuilder: &authorizationBuilder{
logger: logger,
mutex: sync.RWMutex{},
},
}
}
// Init will configure the APNS output binding using the metadata specified
// in the binding's configuration.
func (a *APNS) Init(metadata bindings.Metadata) error {
if err := a.makeURLPrefix(metadata); err != nil {
return err
}
if err := a.extractKeyID(metadata); err != nil {
return err
}
if err := a.extractTeamID(metadata); err != nil {
return err
}
return a.extractPrivateKey(metadata)
}
// Operations will return the set of operations supported by the APNS output
// binding. The APNS output binding only supports the "create" operation for
// sending new push notifications to the APNS service.
func (a *APNS) Operations() []bindings.OperationKind {
return []bindings.OperationKind{bindings.CreateOperation}
}
// Invoke is called by Dapr to send a push notification to the APNS output
// binding.
func (a *APNS) Invoke(req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
if req.Operation != bindings.CreateOperation {
return nil, fmt.Errorf("operation not supported: %v", req.Operation)
}
return a.sendPushNotification(req)
}
func (a *APNS) sendPushNotification(req *bindings.InvokeRequest) (*bindings.InvokeResponse, error) {
deviceToken, ok := req.Metadata[deviceTokenKey]
if !ok || deviceToken == "" {
return nil, errors.New("the device-token parameter is required")
}
httpResponse, err := a.sendPushNotificationToAPNS(deviceToken, req)
if err != nil {
return nil, err
}
defer httpResponse.Body.Close()
if httpResponse.StatusCode == http.StatusOK {
return makeSuccessResponse(httpResponse)
}
return makeErrorResponse(httpResponse)
}
func (a *APNS) sendPushNotificationToAPNS(deviceToken string, req *bindings.InvokeRequest) (*http.Response, error) {
url := a.urlPrefix + deviceToken
httpRequest, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
url,
bytes.NewReader(req.Data),
)
if err != nil {
return nil, err
}
authorizationHeader, err := a.authorizationBuilder.getAuthorizationHeader()
if err != nil {
return nil, err
}
httpRequest.Header.Add("authorization", authorizationHeader)
addRequestHeader(pushTypeKey, req.Metadata, httpRequest)
addRequestHeader(messageIDKey, req.Metadata, httpRequest)
addRequestHeader(expirationKey, req.Metadata, httpRequest)
addRequestHeader(priorityKey, req.Metadata, httpRequest)
addRequestHeader(topicKey, req.Metadata, httpRequest)
addRequestHeader(collapseIDKey, req.Metadata, httpRequest)
return a.client.Do(httpRequest)
}
func (a *APNS) makeURLPrefix(metadata bindings.Metadata) error {
if value, ok := metadata.Properties[developmentKey]; ok && value != "" {
switch value {
case "true":
a.logger.Debug("Using the development APNS service")
a.urlPrefix = developmentPrefix
case "false":
a.logger.Debug("Using the production APNS service")
a.urlPrefix = productionPrefix
default:
return fmt.Errorf(
"invalid value for development parameter: %v",
value,
)
}
} else {
a.logger.Debug("Using the production APNS service")
a.urlPrefix = productionPrefix
}
return nil
}
func (a *APNS) extractKeyID(metadata bindings.Metadata) error {
if value, ok := metadata.Properties[keyIDKey]; ok && value != "" {
a.authorizationBuilder.keyID = value
return nil
}
return errors.New("the key-id parameter is required")
}
func (a *APNS) extractTeamID(metadata bindings.Metadata) error {
if value, ok := metadata.Properties[teamIDKey]; ok && value != "" {
a.authorizationBuilder.teamID = value
return nil
}
return errors.New("the team-id parameter is required")
}
func (a *APNS) extractPrivateKey(metadata bindings.Metadata) error {
if value, ok := metadata.Properties[privateKeyKey]; ok && value != "" {
block, _ := pem.Decode([]byte(value))
if block == nil {
return errors.New("unable to read the private key")
}
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return err
}
a.authorizationBuilder.privateKey = privateKey
} else {
return errors.New("the private-key parameter is required")
}
return nil
}
func addRequestHeader(key string, metadata map[string]string, httpRequest *http.Request) {
if value, ok := metadata[key]; ok && value != "" {
httpRequest.Header.Add(key, value)
}
}
func makeSuccessResponse(httpResponse *http.Response) (*bindings.InvokeResponse, error) {
messageID := httpResponse.Header.Get(messageIDKey)
output := notificationResponse{MessageID: messageID}
var data bytes.Buffer
encoder := jsoniter.NewEncoder(&data)
err := encoder.Encode(output)
if err != nil {
return nil, err
}
return &bindings.InvokeResponse{Data: data.Bytes()}, nil
}
func makeErrorResponse(httpResponse *http.Response) (*bindings.InvokeResponse, error) {
var errorReply errorResponse
decoder := jsoniter.NewDecoder(httpResponse.Body)
err := decoder.Decode(&errorReply)
if err == nil {
err = errors.New(errorReply.Reason)
}
return nil, err
}

344
bindings/apns/apns_test.go Normal file
View File

@ -0,0 +1,344 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// ------------------------------------------------------------
package apns
import (
"bytes"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/dapr/components-contrib/bindings"
"github.com/dapr/dapr/pkg/logger"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
)
const (
testKeyID = "012345678"
testTeamID = "876543210"
// This is a valid PKCS #8 payload, but the key was generated for testing
// use and is not being used in any production service.
testPrivateKey = "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgHZdKErL0xQ3yalg+\nbUMpTpfo4bRVxYMnowSMkBIS3OSgCgYIKoZIzj0DAQehRANCAARjr0Ft+hWAeAfY\nkkOBk8GzMlV4Mo/APwcuXRlAHqkSUKi453YqgAPygkCNBmOhNWgynUp+XGxuj6in\nofsBN1Rw\n-----END PRIVATE KEY-----"
)
func TestInit(t *testing.T) {
testLogger := logger.NewLogger("test")
t.Run("uses the development service", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
developmentKey: "true",
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Nil(t, err)
assert.Equal(t, developmentPrefix, binding.urlPrefix)
})
t.Run("uses the production service", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
developmentKey: "false",
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Nil(t, err)
assert.Equal(t, productionPrefix, binding.urlPrefix)
})
t.Run("defaults to the production service", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Nil(t, err)
assert.Equal(t, productionPrefix, binding.urlPrefix)
})
t.Run("invalid development value", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
developmentKey: "True",
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Error(t, err, "invalid value for development parameter: True")
})
t.Run("the key ID is required", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Error(t, err, "the key-id parameter is required")
})
t.Run("valid key ID", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Nil(t, err)
assert.Equal(t, testKeyID, binding.authorizationBuilder.keyID)
})
t.Run("the team ID is required", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
keyIDKey: testKeyID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Error(t, err, "the team-id parameter is required")
})
t.Run("valid team ID", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Nil(t, err)
assert.Equal(t, testTeamID, binding.authorizationBuilder.teamID)
})
t.Run("the private key is required", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
keyIDKey: testKeyID,
teamIDKey: testTeamID,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Error(t, err, "the private-key parameter is required")
})
t.Run("valid private key", func(t *testing.T) {
metadata := bindings.Metadata{
Properties: map[string]string{
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
binding := NewAPNS(testLogger)
err := binding.Init(metadata)
assert.Nil(t, err)
assert.NotNil(t, binding.authorizationBuilder.privateKey)
})
}
func TestOperations(t *testing.T) {
testLogger := logger.NewLogger("test")
testBinding := NewAPNS(testLogger)
operations := testBinding.Operations()
assert.Equal(t, 1, len(operations))
assert.Equal(t, bindings.CreateOperation, operations[0])
}
func TestInvoke(t *testing.T) {
testLogger := logger.NewLogger("test")
successRequest := &bindings.InvokeRequest{
Operation: bindings.CreateOperation,
Metadata: map[string]string{
deviceTokenKey: "1234567890",
pushTypeKey: "alert",
messageIDKey: "123",
expirationKey: "1234567890",
priorityKey: "10",
topicKey: "test",
collapseIDKey: "1234567",
},
}
t.Run("operation must be create", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
req := &bindings.InvokeRequest{Operation: bindings.DeleteOperation}
_, err := testBinding.Invoke(req)
assert.Error(t, err, "operation not supported: delete")
})
t.Run("the device token is required", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
req := &bindings.InvokeRequest{
Operation: bindings.CreateOperation,
Metadata: map[string]string{},
}
_, err := testBinding.Invoke(req)
assert.Error(t, err, "the device-token parameter is required")
})
t.Run("the authorization header is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Authorization")
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the push type header is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Apns-Push-Type")
assert.Equal(t, "alert", req.Header.Get(pushTypeKey))
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the message ID is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Apns-Id")
assert.Equal(t, "123", req.Header.Get(messageIDKey))
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the expiration is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Apns-Expiration")
assert.Equal(t, "1234567890", req.Header.Get(expirationKey))
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the priority is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Apns-Priority")
assert.Equal(t, "10", req.Header.Get(priorityKey))
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the topic is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Apns-Topic")
assert.Equal(t, "test", req.Header.Get(topicKey))
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the collapse ID is sent", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
assert.Contains(t, req.Header, "Apns-Collapse-Id")
assert.Equal(t, "1234567", req.Header.Get(collapseIDKey))
return successResponse()
})
_, _ = testBinding.Invoke(successRequest)
})
t.Run("the message ID is returned", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
return successResponse()
})
response, err := testBinding.Invoke(successRequest)
assert.Nil(t, err)
assert.NotNil(t, response.Data)
var body notificationResponse
decoder := jsoniter.NewDecoder(bytes.NewReader(response.Data))
err = decoder.Decode(&body)
assert.Nil(t, err)
assert.Equal(t, "12345", body.MessageID)
})
t.Run("returns the error code", func(t *testing.T) {
testBinding := makeTestBinding(t, testLogger)
testBinding.client = newTestClient(func(req *http.Request) *http.Response {
body := "{\"reason\":\"BadDeviceToken\"}"
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: ioutil.NopCloser(strings.NewReader(body)),
}
})
_, err := testBinding.Invoke(successRequest)
assert.Error(t, err, "BadDeviceToken")
})
}
func makeTestBinding(t *testing.T, log logger.Logger) *APNS {
testBinding := NewAPNS(log)
bindingMetadata := bindings.Metadata{
Properties: map[string]string{
developmentKey: "true",
keyIDKey: testKeyID,
teamIDKey: testTeamID,
privateKeyKey: testPrivateKey,
},
}
err := testBinding.Init(bindingMetadata)
assert.Nil(t, err)
return testBinding
}
func successResponse() *http.Response {
response := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
}
response.Header.Add(messageIDKey, "12345")
return response
}
// http://hassansin.github.io/Unit-Testing-http-client-in-Go
type roundTripFunc func(req *http.Request) *http.Response
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func newTestClient(fn roundTripFunc) *http.Client {
return &http.Client{Transport: fn}
}

View File

@ -0,0 +1,74 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// ------------------------------------------------------------
package apns
import (
"sync"
"time"
"github.com/dapr/dapr/pkg/logger"
"github.com/dgrijalva/jwt-go"
)
// The "issued at" timestamp in the JWT must be within one hour from the
// APNS server time. I set the expiration time at 55 minutes to ensure that
// a new certificate gets generated before it gets too close and risking a
// failure.
const expirationMinutes = time.Minute * 55
type authorizationBuilder struct {
logger logger.Logger
mutex sync.RWMutex
authorizationHeader string
tokenExpiresAt time.Time
keyID string
teamID string
privateKey interface{}
}
func (a *authorizationBuilder) getAuthorizationHeader() (string, error) {
authorizationHeader, ok := a.readAuthorizationHeader()
if ok {
return authorizationHeader, nil
}
return a.generateAuthorizationHeader()
}
func (a *authorizationBuilder) readAuthorizationHeader() (string, bool) {
a.mutex.RLock()
defer a.mutex.RUnlock()
if time.Now().After(a.tokenExpiresAt) {
return "", false
}
return a.authorizationHeader, true
}
func (a *authorizationBuilder) generateAuthorizationHeader() (string, error) {
a.mutex.Lock()
defer a.mutex.Unlock()
a.logger.Debug("Authorization token expired; generating new token")
now := time.Now()
claims := jwt.StandardClaims{
IssuedAt: now.Unix(),
Issuer: a.teamID,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = a.keyID
signedToken, err := token.SignedString(a.privateKey)
if err != nil {
return "", err
}
a.authorizationHeader = "bearer " + signedToken
a.tokenExpiresAt = now.Add(expirationMinutes)
return a.authorizationHeader, nil
}

161
bindings/apns/doc.go Normal file
View File

@ -0,0 +1,161 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
// ------------------------------------------------------------
// Package apns implements an output binding for Dapr that allows services to
// send push notifications to Apple devices and Mac computers using Apple's
// Push Notification Service (APNS).
//
// Configuring the Binding
//
// To use the APNS output binding, you will need to create the binding
// configuration and add it to your components directory. The binding
// configuration will contain parameters that will allow the binding to
// connect to the APNS service specified as metadata.
//
// The APNS binding will need a cryptographic private key in order to generate
// authentication tokens for the APNS service. The private key can be generated
// from the Apple Developer Portal and is provided as a PKCS #8 file with the
// private key stored in PEM format. The private key should be stored in the
// Dapr secret store and not stored directly in the binding's configuration
// file.
//
// A sample configuration file for the APNS binding is shown below:
//
// apiVersion: dapr.io/v1alpha1
// kind: Component
// metadata:
// name: apns
// namespace: default
// spec:
// type: bindings.apns
// metadata:
// - name: development
// value: false
// - name: key-id
// value: PUT-KEY-ID-HERE
// - name: team-id
// value: PUT-APPLE-TEAM-ID-HERE
// - name: private-key
// secretKeyRef:
// name: apns-secrets
// key: private-key
//
// If using Kubernetes, a sample secret configuration may look like this:
//
// apiVersion: v1
// kind: Secret
// metadata:
// name: apns-secrets
// namespace: default
// stringData:
// private-key: |
// -----BEGIN PRIVATE KEY-----
// KEY-DATA-GOES-HERE
// -----END PRIVATE KEY-----
//
// The development parameter can be either "true" or "false". The development
// parameter controls which APNS service is used. If development is set to
// true, then the sandbox APNS service will be used to send push notifications
// to devices. If development is set to false, the production APNS service will
// be used to send push notifications. If not specified, the production service
// will be chosen by default.
//
// Push Notification Format
//
// The APNS binding is a pass-through wrapper over the Apple Push Notification
// Service. The APNS binding will send the request directly to the APNS service
// without any translation. It is therefore important to understand the payload
// for push notifications expected by the APNS service. The payload format is
// documented at https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification.
//
// Requests sent to the APNS binding should be a JSON object. A simple push
// notification appears below:
//
// {
// "aps": {
// "alert": {
// "title": "New Updates!",
// "body": "New updates are now available for your review."
// }
// }
// }
//
// The aps child object contains the push notification details that are used
// by the Apple Push Notification Service and target devices to route and show
// the push notification. Additional objects or values can be added to the push
// notification envelope for use by applications to handle the push
// notification.
//
// The APNS binding accepts several metadata values that are mapped directly
// to HTTP headers in the APNS publish request. Below is a summary of the valid
// metadata fields. For more information, please see
// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification.
//
// * apns-push-type: Identifies the content of the notification payload. One of
// alert, background, voip, complication, fileprovider, mdm.
//
// * apns-id: a UUID that uniquely identifies the push notification. This value
// is returned by APNS if provided and can be used to track notifications.
//
// * apns-expiration: The date/time at which the notification is no longer
// valid and should not be delivered. This value is the number of seconds
// since the UNIX epoch (January 1, 1970 at 00:00 UTC). If not specified or
// if 0, the message is sent once immediately and then discarded.
//
// * apns-priority: If 10, the notification is sent immediately. If 5, the
// notification is sent based on power conditions of the user's device.
// Defaults to 10.
//
// * apns-topic: The topic for the notification. Typically this is the bundle
// identifier of the target app.
//
// * apns-collapse-id: A correlation identifier that will cause notifications
// to be displayed as a group on the target device. For example, multiple
// notifications from a chat room may have the same identifier causing them
// to show up together in the device's notifications list.
//
// Sending a Push Notification Using the APNS Binding
//
// A simple request to the APNS binding looks like this:
//
// {
// "data": {
// "aps": {
// "alert": {
// "title": "New Updates!",
// "body": "New updates are available for your review."
// }
// }
// },
// "metadata": {
// "device-token": "PUT-DEVICE-TOKEN-HERE",
// "apns-push-type": "alert",
// "apns-priority": "10",
// "apns-topic": "com.example.helloworld"
// },
// "operation": "create"
// }
//
// The device-token metadata field is required and should contain the token
// for the device that will receive the push notification. Only one device
// can be specified per request to the APNS binding.
//
// The APNS binding only supports one operation: create. Specifying any other
// operation name will result in a runtime error.
//
// If the push notification is successfully sent, the response will be a JSON
// object containing the message ID. If a message ID was not specified using
// the apns-id metadata value, then the Apple Push Notification Serivce will
// generate a unique ID and will return it.
//
// {
// "messageID": "12345678-1234-1234-1234-1234567890AB"
// }
//
// If the push notification could not be sent due to an authentication error
// or payload error, the error code returned by Apple will be returned. For
// a list of error codes and their meanings, see
// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/handling_notification_responses_from_apns.
package apns