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:
parent
2c403ad238
commit
b08e920cdd
|
@ -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
|
||||
}
|
|
@ -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}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
Loading…
Reference in New Issue