diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a8c4af..ea15a5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,22 @@ # Unreleased -### Token revocation -- [added] A New ['VerifyIDTokenAndCheckRevoked(ctx, token)'](https://godoc.org/firebase.google.com/go/auth#Client.VerifyIDToken) - method has been added to check for revoked ID tokens. -- [added] A new method ['RevokeRefreshTokens(uid)'](https://godoc.org/firebase.google.com/go/auth#Client.RevokeRefreshTokens) - has been added to invalidate all refresh tokens issued to a user. -- [added] A new property - `TokensValidAfterMillis` has been added to the ['UserRecord'](https://godoc.org/firebase.google.com/go/auth#UserRecord). - This property stores the time of the revocation truncated to 1 second accuracy. - - Import context from golang.org/x/net/ for 1.6 compatibility +### Cloud Messaging + +- [feature] Added the `messaging` package for sending Firebase notifications + and managing topic subscriptions. + +### Authentication + +- [added] A new [`VerifyIDTokenAndCheckRevoked()`](https://godoc.org/firebase.google.com/go/auth#Client.VerifyIDToken) + function has been added to check for revoked ID tokens. +- [added] A new [`RevokeRefreshTokens()`](https://godoc.org/firebase.google.com/go/auth#Client.RevokeRefreshTokens) + function has been added to invalidate all refresh tokens issued to a user. +- [added] A new property `TokensValidAfterMillis` has been added to the + ['UserRecord'](https://godoc.org/firebase.google.com/go/auth#UserRecord) + type, which stores the time of the revocation truncated to 1 second accuracy. + # v2.4.0 ### Initialization diff --git a/firebase.go b/firebase.go index a0e5085c..9ed07125 100644 --- a/firebase.go +++ b/firebase.go @@ -28,6 +28,7 @@ import ( "firebase.google.com/go/auth" "firebase.google.com/go/iid" "firebase.google.com/go/internal" + "firebase.google.com/go/messaging" "firebase.google.com/go/storage" "golang.org/x/net/context" @@ -43,6 +44,7 @@ var firebaseScopes = []string{ "https://www.googleapis.com/auth/firebase", "https://www.googleapis.com/auth/identitytoolkit", "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/firebase.messaging", } // Version of the Firebase Go Admin SDK. @@ -103,6 +105,16 @@ func (a *App) InstanceID(ctx context.Context) (*iid.Client, error) { return iid.NewClient(ctx, conf) } +// Messaging returns an instance of messaging.Client. +func (a *App) Messaging(ctx context.Context) (*messaging.Client, error) { + conf := &internal.MessagingConfig{ + ProjectID: a.projectID, + Opts: a.opts, + Version: Version, + } + return messaging.NewClient(ctx, conf) +} + // NewApp creates a new App from the provided config and client options. // // If the client options contain a valid credential (a service account file, a refresh token diff --git a/firebase_test.go b/firebase_test.go index df41c56d..fc33ba20 100644 --- a/firebase_test.go +++ b/firebase_test.go @@ -304,6 +304,18 @@ func TestInstanceID(t *testing.T) { } } +func TestMessaging(t *testing.T) { + ctx := context.Background() + app, err := NewApp(ctx, nil, option.WithCredentialsFile("testdata/service_account.json")) + if err != nil { + t.Fatal(err) + } + + if c, err := app.Messaging(ctx); c == nil || err != nil { + t.Errorf("Messaging() = (%v, %v); want (iid, nil)", c, err) + } +} + func TestCustomTokenSource(t *testing.T) { ctx := context.Background() ts := &testTokenSource{AccessToken: "mock-token-from-custom"} diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go new file mode 100644 index 00000000..d7bb0693 --- /dev/null +++ b/integration/messaging/messaging_test.go @@ -0,0 +1,125 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "flag" + "log" + "os" + "regexp" + "testing" + + "golang.org/x/net/context" + + "firebase.google.com/go/integration/internal" + "firebase.google.com/go/messaging" +) + +// The registration token has the proper format, but is not valid (i.e. expired). The intention of +// these integration tests is to verify that the endpoints return the proper payload, but it is +// hard to ensure this token remains valid. The tests below should still pass regardless. +const testRegistrationToken = "fGw0qy4TGgk:APA91bGtWGjuhp4WRhHXgbabIYp1jxEKI08ofj_v1bKhWAGJQ4e3a" + + "rRCWzeTfHaLz83mBnDh0aPWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE" + +var client *messaging.Client + +// Enable API before testing +// https://console.developers.google.com/apis/library/fcm.googleapis.com +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + log.Println("Skipping messaging integration tests in short mode.") + return + } + + ctx := context.Background() + app, err := internal.NewTestApp(ctx) + if err != nil { + log.Fatalln(err) + } + + client, err = app.Messaging(ctx) + if err != nil { + log.Fatalln(err) + } + os.Exit(m.Run()) +} + +func TestSend(t *testing.T) { + msg := &messaging.Message{ + Topic: "foo-bar", + Notification: &messaging.Notification{ + Title: "Title", + Body: "Body", + }, + Android: &messaging.AndroidConfig{ + Notification: &messaging.AndroidNotification{ + Title: "Android Title", + Body: "Android Body", + }, + }, + APNS: &messaging.APNSConfig{ + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + Alert: &messaging.ApsAlert{ + Title: "APNS Title", + Body: "APNS Body", + }, + }, + }, + }, + Webpush: &messaging.WebpushConfig{ + Notification: &messaging.WebpushNotification{ + Title: "Webpush Title", + Body: "Webpush Body", + }, + }, + } + name, err := client.SendDryRun(context.Background(), msg) + if err != nil { + log.Fatalln(err) + } + const pattern = "^projects/.*/messages/.*$" + if !regexp.MustCompile(pattern).MatchString(name) { + t.Errorf("Send() = %q; want = %q", name, pattern) + } +} + +func TestSendInvalidToken(t *testing.T) { + msg := &messaging.Message{Token: "INVALID_TOKEN"} + if _, err := client.Send(context.Background(), msg); err == nil { + t.Errorf("Send() = nil; want error") + } +} + +func TestSubscribe(t *testing.T) { + tmr, err := client.SubscribeToTopic(context.Background(), []string{testRegistrationToken}, "mock-topic") + if err != nil { + t.Fatal(err) + } + if tmr.SuccessCount+tmr.FailureCount != 1 { + t.Errorf("SubscribeToTopic() = %v; want total 1", tmr) + } +} + +func TestUnsubscribe(t *testing.T) { + tmr, err := client.UnsubscribeFromTopic(context.Background(), []string{testRegistrationToken}, "mock-topic") + if err != nil { + t.Fatal(err) + } + if tmr.SuccessCount+tmr.FailureCount != 1 { + t.Errorf("UnsubscribeFromTopic() = %v; want total 1", tmr) + } +} diff --git a/internal/internal.go b/internal/internal.go index 34c4f32d..225edc9e 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -46,6 +46,13 @@ type MockTokenSource struct { AccessToken string } +// MessagingConfig represents the configuration of Firebase Cloud Messaging service. +type MessagingConfig struct { + Opts []option.ClientOption + ProjectID string + Version string +} + // Token returns the test token associated with the TokenSource. func (ts *MockTokenSource) Token() (*oauth2.Token, error) { return &oauth2.Token{AccessToken: ts.AccessToken}, nil diff --git a/messaging/messaging.go b/messaging/messaging.go new file mode 100644 index 00000000..97b77d64 --- /dev/null +++ b/messaging/messaging.go @@ -0,0 +1,475 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package messaging contains functions for sending messages and managing +// device subscriptions with Firebase Cloud Messaging (FCM). +package messaging + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "golang.org/x/net/context" + + "firebase.google.com/go/internal" + "google.golang.org/api/transport" +) + +const ( + messagingEndpoint = "https://fcm.googleapis.com/v1" + iidEndpoint = "https://iid.googleapis.com" + iidSubscribe = "iid/v1:batchAdd" + iidUnsubscribe = "iid/v1:batchRemove" +) + +var ( + topicNamePattern = regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$") + + fcmErrorCodes = map[string]string{ + "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", + "UNREGISTERED": "app instance has been unregistered; code: registration-token-not-registered", + "SENDER_ID_MISMATCH": "sender id does not match regisration token; code: authentication-error", + "QUOTA_EXCEEDED": "messaging service quota exceeded; code: message-rate-exceeded", + "APNS_AUTH_ERROR": "apns certificate or auth key was invalid; code: authentication-error", + "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", + "INTERNAL": "back servers encountered an unknown internl error; code: internal-error", + } + + iidErrorCodes = map[string]string{ + "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", + "NOT_FOUND": "request contains an invalid argument; code: registration-token-not-registered", + "INTERNAL": "server encountered an internal error; code: internal-error", + "TOO_MANY_TOPICS": "client exceeded the number of allowed topics; code: too-many-topics", + } +) + +// Client is the interface for the Firebase Cloud Messaging (FCM) service. +type Client struct { + fcmEndpoint string // to enable testing against arbitrary endpoints + iidEndpoint string // to enable testing against arbitrary endpoints + client *internal.HTTPClient + project string + version string +} + +// Message to be sent via Firebase Cloud Messaging. +// +// Message contains payload data, recipient information and platform-specific configuration +// options. A Message must specify exactly one of Token, Topic or Condition fields. Apart from +// that a Message may specify any combination of Data, Notification, Android, Webpush and APNS +// fields. See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages for more +// details on how the backend FCM servers handle different message parameters. +type Message struct { + Data map[string]string `json:"data,omitempty"` + Notification *Notification `json:"notification,omitempty"` + Android *AndroidConfig `json:"android,omitempty"` + Webpush *WebpushConfig `json:"webpush,omitempty"` + APNS *APNSConfig `json:"apns,omitempty"` + Token string `json:"token,omitempty"` + Topic string `json:"-"` + Condition string `json:"condition,omitempty"` +} + +// MarshalJSON marshals a Message into JSON (for internal use only). +func (m *Message) MarshalJSON() ([]byte, error) { + // Create a new type to prevent infinite recursion. + type messageInternal Message + s := &struct { + BareTopic string `json:"topic,omitempty"` + *messageInternal + }{ + BareTopic: strings.TrimPrefix(m.Topic, "/topics/"), + messageInternal: (*messageInternal)(m), + } + return json.Marshal(s) +} + +// Notification is the basic notification template to use across all platforms. +type Notification struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` +} + +// AndroidConfig contains messaging options specific to the Android platform. +type AndroidConfig struct { + CollapseKey string `json:"collapse_key,omitempty"` + Priority string `json:"priority,omitempty"` // one of "normal" or "high" + TTL *time.Duration `json:"-"` + RestrictedPackageName string `json:"restricted_package_name,omitempty"` + Data map[string]string `json:"data,omitempty"` // if specified, overrides the Data field on Message type + Notification *AndroidNotification `json:"notification,omitempty"` +} + +// MarshalJSON marshals an AndroidConfig into JSON (for internal use only). +func (a *AndroidConfig) MarshalJSON() ([]byte, error) { + var ttl string + if a.TTL != nil { + seconds := int64(*a.TTL / time.Second) + nanos := int64((*a.TTL - time.Duration(seconds)*time.Second) / time.Nanosecond) + if nanos > 0 { + ttl = fmt.Sprintf("%d.%09ds", seconds, nanos) + } else { + ttl = fmt.Sprintf("%ds", seconds) + } + } + + type androidInternal AndroidConfig + s := &struct { + TTL string `json:"ttl,omitempty"` + *androidInternal + }{ + TTL: ttl, + androidInternal: (*androidInternal)(a), + } + return json.Marshal(s) +} + +// AndroidNotification is a notification to send to Android devices. +type AndroidNotification struct { + Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` // notification color in #RRGGBB format + Sound string `json:"sound,omitempty"` + Tag string `json:"tag,omitempty"` + ClickAction string `json:"click_action,omitempty"` + BodyLocKey string `json:"body_loc_key,omitempty"` + BodyLocArgs []string `json:"body_loc_args,omitempty"` + TitleLocKey string `json:"title_loc_key,omitempty"` + TitleLocArgs []string `json:"title_loc_args,omitempty"` +} + +// WebpushConfig contains messaging options specific to the WebPush protocol. +// +// See https://tools.ietf.org/html/rfc8030#section-5 for additional details, and supported +// headers. +type WebpushConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Data map[string]string `json:"data,omitempty"` + Notification *WebpushNotification `json:"notification,omitempty"` +} + +// WebpushNotification is a notification to send via WebPush protocol. +type WebpushNotification struct { + Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type + Icon string `json:"icon,omitempty"` +} + +// APNSConfig contains messaging options specific to the Apple Push Notification Service (APNS). +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html +// for more details on supported headers and payload keys. +type APNSConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Payload *APNSPayload `json:"payload,omitempty"` +} + +// APNSPayload is the payload that can be included in an APNS message. +// +// The payload mainly consists of the aps dictionary. Additionally it may contain arbitrary +// key-values pairs as custom data fields. +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html +// for a full list of supported payload fields. +type APNSPayload struct { + Aps *Aps + CustomData map[string]interface{} +} + +// MarshalJSON marshals an APNSPayload into JSON (for internal use only). +func (p *APNSPayload) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{"aps": p.Aps} + for k, v := range p.CustomData { + m[k] = v + } + return json.Marshal(m) +} + +// Aps represents the aps dictionary that may be included in an APNSPayload. +// +// Alert may be specified as a string (via the AlertString field), or as a struct (via the Alert +// field). +type Aps struct { + AlertString string `json:"-"` + Alert *ApsAlert `json:"-"` + Badge *int `json:"badge,omitempty"` + Sound string `json:"sound,omitempty"` + ContentAvailable bool `json:"-"` + Category string `json:"category,omitempty"` + ThreadID string `json:"thread-id,omitempty"` +} + +// MarshalJSON marshals an Aps into JSON (for internal use only). +func (a *Aps) MarshalJSON() ([]byte, error) { + type apsAlias Aps + s := &struct { + Alert interface{} `json:"alert,omitempty"` + ContentAvailable *int `json:"content-available,omitempty"` + *apsAlias + }{ + apsAlias: (*apsAlias)(a), + } + + if a.Alert != nil { + s.Alert = a.Alert + } else if a.AlertString != "" { + s.Alert = a.AlertString + } + if a.ContentAvailable { + one := 1 + s.ContentAvailable = &one + } + return json.Marshal(s) +} + +// ApsAlert is the alert payload that can be included in an Aps. +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html +// for supported fields. +type ApsAlert struct { + Title string `json:"title,omitempty"` // if specified, overrides the Title field of the Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of the Notification type + LocKey string `json:"loc-key,omitempty"` + LocArgs []string `json:"loc-args,omitempty"` + TitleLocKey string `json:"title-loc-key,omitempty"` + TitleLocArgs []string `json:"title-loc-args,omitempty"` + ActionLocKey string `json:"action-loc-key,omitempty"` + LaunchImage string `json:"launch-image,omitempty"` +} + +// ErrorInfo is a topic management error. +type ErrorInfo struct { + Index int + Reason string +} + +// TopicManagementResponse is the result produced by topic management operations. +// +// TopicManagementResponse provides an overview of how many input tokens were successfully handled, +// and how many failed. In case of failures, the Errors list provides specific details concerning +// each error. +type TopicManagementResponse struct { + SuccessCount int + FailureCount int + Errors []*ErrorInfo +} + +func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse { + tmr := &TopicManagementResponse{} + for idx, res := range resp.Results { + if len(res) == 0 { + tmr.SuccessCount++ + } else { + tmr.FailureCount++ + code := res["error"].(string) + reason := iidErrorCodes[code] + if reason == "" { + reason = "unknown-error" + } + tmr.Errors = append(tmr.Errors, &ErrorInfo{ + Index: idx, + Reason: reason, + }) + } + } + return tmr +} + +// NewClient creates a new instance of the Firebase Cloud Messaging Client. +// +// This function can only be invoked from within the SDK. Client applications should access the +// the messaging service through firebase.App. +func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error) { + if c.ProjectID == "" { + return nil, errors.New("project ID is required to access Firebase Cloud Messaging client") + } + + hc, _, err := transport.NewHTTPClient(ctx, c.Opts...) + if err != nil { + return nil, err + } + + return &Client{ + fcmEndpoint: messagingEndpoint, + iidEndpoint: iidEndpoint, + client: &internal.HTTPClient{Client: hc}, + project: c.ProjectID, + version: "Go/Admin/" + c.Version, + }, nil +} + +// Send sends a Message to Firebase Cloud Messaging. +// +// The Message must specify exactly one of Token, Topic and Condition fields. FCM will +// customize the message for each target platform based on the arguments specified in the +// Message. +func (c *Client) Send(ctx context.Context, message *Message) (string, error) { + payload := &fcmRequest{ + Message: message, + } + return c.makeSendRequest(ctx, payload) +} + +// SendDryRun sends a Message to Firebase Cloud Messaging in the dry run (validation only) mode. +// +// This function does not actually deliver the message to target devices. Instead, it performs all +// the SDK-level and backend validations on the message, and emulates the send operation. +func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, error) { + payload := &fcmRequest{ + ValidateOnly: true, + Message: message, + } + return c.makeSendRequest(ctx, payload) +} + +// SubscribeToTopic subscribes a list of registration tokens to a topic. +// +// The tokens list must not be empty, and have at most 1000 tokens. +func (c *Client) SubscribeToTopic(ctx context.Context, tokens []string, topic string) (*TopicManagementResponse, error) { + req := &iidRequest{ + Topic: topic, + Tokens: tokens, + op: iidSubscribe, + } + return c.makeTopicManagementRequest(ctx, req) +} + +// UnsubscribeFromTopic unsubscribes a list of registration tokens from a topic. +// +// The tokens list must not be empty, and have at most 1000 tokens. +func (c *Client) UnsubscribeFromTopic(ctx context.Context, tokens []string, topic string) (*TopicManagementResponse, error) { + req := &iidRequest{ + Topic: topic, + Tokens: tokens, + op: iidSubscribe, + } + return c.makeTopicManagementRequest(ctx, req) +} + +type fcmRequest struct { + ValidateOnly bool `json:"validate_only,omitempty"` + Message *Message `json:"message,omitempty"` +} + +type fcmResponse struct { + Name string `json:"name"` +} + +type fcmError struct { + Error struct { + Status string `json:"status"` + } `json:"error"` +} + +type iidRequest struct { + Topic string `json:"to"` + Tokens []string `json:"registration_tokens"` + op string +} + +type iidResponse struct { + Results []map[string]interface{} `json:"results"` +} + +type iidError struct { + Error string `json:"error"` +} + +func (c *Client) makeSendRequest(ctx context.Context, req *fcmRequest) (string, error) { + if err := validateMessage(req.Message); err != nil { + return "", err + } + + request := &internal.Request{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s/projects/%s/messages:send", c.fcmEndpoint, c.project), + Body: internal.NewJSONEntity(req), + } + resp, err := c.client.Do(ctx, request) + if err != nil { + return "", err + } + + if resp.Status == http.StatusOK { + var result fcmResponse + err := json.Unmarshal(resp.Body, &result) + return result.Name, err + } + + var fe fcmError + json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level + msg := fcmErrorCodes[fe.Error.Status] + if msg == "" { + msg = fmt.Sprintf("server responded with an unknown error; response: %s", string(resp.Body)) + } + return "", fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) +} + +func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest) (*TopicManagementResponse, error) { + if len(req.Tokens) == 0 { + return nil, fmt.Errorf("no tokens specified") + } + if len(req.Tokens) > 1000 { + return nil, fmt.Errorf("tokens list must not contain more than 1000 items") + } + for _, token := range req.Tokens { + if token == "" { + return nil, fmt.Errorf("tokens list must not contain empty strings") + } + } + + if req.Topic == "" { + return nil, fmt.Errorf("topic name not specified") + } + if !topicNamePattern.MatchString(req.Topic) { + return nil, fmt.Errorf("invalid topic name: %q", req.Topic) + } + + if !strings.HasPrefix(req.Topic, "/topics/") { + req.Topic = "/topics/" + req.Topic + } + + request := &internal.Request{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s/%s", c.iidEndpoint, req.op), + Body: internal.NewJSONEntity(req), + Opts: []internal.HTTPOption{internal.WithHeader("access_token_auth", "true")}, + } + resp, err := c.client.Do(ctx, request) + if err != nil { + return nil, err + } + + if resp.Status == http.StatusOK { + var result iidResponse + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, err + } + return newTopicManagementResponse(&result), nil + } + + var ie iidError + json.Unmarshal(resp.Body, &ie) // ignore any json parse errors at this level + msg := iidErrorCodes[ie.Error] + if msg == "" { + msg = fmt.Sprintf("client encountered an unknown error; response: %s", string(resp.Body)) + } + return nil, fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) +} diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go new file mode 100644 index 00000000..27808e84 --- /dev/null +++ b/messaging/messaging_test.go @@ -0,0 +1,870 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "google.golang.org/api/option" + + "firebase.google.com/go/internal" +) + +const testMessageID = "projects/test-project/messages/msg_id" + +var ( + testMessagingConfig = &internal.MessagingConfig{ + ProjectID: "test-project", + Opts: []option.ClientOption{ + option.WithTokenSource(&internal.MockTokenSource{AccessToken: "test-token"}), + }, + } + + ttlWithNanos = time.Duration(1500) * time.Millisecond + ttl = time.Duration(10) * time.Second + invalidTTL = time.Duration(-10) * time.Second + + badge = 42 + badgeZero = 0 +) + +var validMessages = []struct { + name string + req *Message + want map[string]interface{} +}{ + { + name: "TokenOnly", + req: &Message{Token: "test-token"}, + want: map[string]interface{}{"token": "test-token"}, + }, + { + name: "TopicOnly", + req: &Message{Topic: "test-topic"}, + want: map[string]interface{}{"topic": "test-topic"}, + }, + { + name: "PrefixedTopicOnly", + req: &Message{Topic: "/topics/test-topic"}, + want: map[string]interface{}{"topic": "test-topic"}, + }, + { + name: "ConditionOnly", + req: &Message{Condition: "test-condition"}, + want: map[string]interface{}{"condition": "test-condition"}, + }, + { + name: "DataMessage", + req: &Message{ + Data: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "data": map[string]interface{}{ + "k1": "v1", + "k2": "v2", + }, + "topic": "test-topic", + }, + }, + { + name: "NotificationMessage", + req: &Message{ + Notification: &Notification{ + Title: "t", + Body: "b", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "notification": map[string]interface{}{ + "title": "t", + "body": "b", + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidDataMessage", + req: &Message{ + Android: &AndroidConfig{ + CollapseKey: "ck", + Data: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + Priority: "normal", + TTL: &ttl, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "collapse_key": "ck", + "data": map[string]interface{}{ + "k1": "v1", + "k2": "v2", + }, + "priority": "normal", + "ttl": "10s", + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidNotificationMessage", + req: &Message{ + Android: &AndroidConfig{ + RestrictedPackageName: "rpn", + Notification: &AndroidNotification{ + Title: "t", + Body: "b", + Color: "#112233", + Sound: "s", + TitleLocKey: "tlk", + TitleLocArgs: []string{"t1", "t2"}, + BodyLocKey: "blk", + BodyLocArgs: []string{"b1", "b2"}, + }, + TTL: &ttlWithNanos, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "restricted_package_name": "rpn", + "notification": map[string]interface{}{ + "title": "t", + "body": "b", + "color": "#112233", + "sound": "s", + "title_loc_key": "tlk", + "title_loc_args": []interface{}{"t1", "t2"}, + "body_loc_key": "blk", + "body_loc_args": []interface{}{"b1", "b2"}, + }, + "ttl": "1.500000000s", + }, + "topic": "test-topic", + }, + }, + { + name: "AndroidNoTTL", + req: &Message{ + Android: &AndroidConfig{ + Priority: "high", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "priority": "high", + }, + "topic": "test-topic", + }, + }, + { + name: "WebpushMessage", + req: &Message{ + Webpush: &WebpushConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + Data: map[string]string{ + "k1": "v1", + "k2": "v2", + }, + Notification: &WebpushNotification{ + Title: "t", + Body: "b", + Icon: "i", + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "webpush": map[string]interface{}{ + "headers": map[string]interface{}{"h1": "v1", "h2": "v2"}, + "data": map[string]interface{}{"k1": "v1", "k2": "v2"}, + "notification": map[string]interface{}{"title": "t", "body": "b", "icon": "i"}, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSHeadersOnly", + req: &Message{ + APNS: &APNSConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "headers": map[string]interface{}{"h1": "v1", "h2": "v2"}, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSAlertString", + req: &Message{ + APNS: &APNSConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + Payload: &APNSPayload{ + Aps: &Aps{ + AlertString: "a", + Badge: &badge, + Category: "c", + Sound: "s", + ThreadID: "t", + ContentAvailable: true, + }, + CustomData: map[string]interface{}{ + "k1": "v1", + "k2": true, + }, + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "headers": map[string]interface{}{"h1": "v1", "h2": "v2"}, + "payload": map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": "a", + "badge": float64(badge), + "category": "c", + "sound": "s", + "thread-id": "t", + "content-available": float64(1), + }, + "k1": "v1", + "k2": true, + }, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSBadgeZero", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Badge: &badgeZero, + Category: "c", + Sound: "s", + ThreadID: "t", + ContentAvailable: true, + }, + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "payload": map[string]interface{}{ + "aps": map[string]interface{}{ + "badge": float64(badgeZero), + "category": "c", + "sound": "s", + "thread-id": "t", + "content-available": float64(1), + }, + }, + }, + "topic": "test-topic", + }, + }, + { + name: "APNSAlertObject", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{ + Title: "t", + Body: "b", + TitleLocKey: "tlk", + TitleLocArgs: []string{"t1", "t2"}, + LocKey: "blk", + LocArgs: []string{"b1", "b2"}, + ActionLocKey: "alk", + LaunchImage: "li", + }, + }, + }, + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "apns": map[string]interface{}{ + "payload": map[string]interface{}{ + "aps": map[string]interface{}{ + "alert": map[string]interface{}{ + "title": "t", + "body": "b", + "title-loc-key": "tlk", + "title-loc-args": []interface{}{"t1", "t2"}, + "loc-key": "blk", + "loc-args": []interface{}{"b1", "b2"}, + "action-loc-key": "alk", + "launch-image": "li", + }, + }, + }, + }, + "topic": "test-topic", + }, + }, +} + +var invalidMessages = []struct { + name string + req *Message + want string +}{ + { + name: "NilMessage", + req: nil, + want: "message must not be nil", + }, + { + name: "NoTargets", + req: &Message{}, + want: "exactly one of token, topic or condition must be specified", + }, + { + name: "MultipleTargets", + req: &Message{ + Token: "token", + Topic: "topic", + }, + want: "exactly one of token, topic or condition must be specified", + }, + { + name: "InvalidPrefixedTopicName", + req: &Message{ + Topic: "/topics/", + }, + want: "malformed topic name", + }, + { + name: "InvalidTopicName", + req: &Message{ + Topic: "foo*bar", + }, + want: "malformed topic name", + }, + { + name: "InvalidAndroidTTL", + req: &Message{ + Android: &AndroidConfig{ + TTL: &invalidTTL, + }, + Topic: "topic", + }, + want: "ttl duration must not be negative", + }, + { + name: "InvalidAndroidPriority", + req: &Message{ + Android: &AndroidConfig{ + Priority: "not normal", + }, + Topic: "topic", + }, + want: "priority must be 'normal' or 'high'", + }, + { + name: "InvalidAndroidColor1", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + Color: "112233", + }, + }, + Topic: "topic", + }, + want: "color must be in the #RRGGBB form", + }, + { + name: "InvalidAndroidColor2", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + Color: "#112233X", + }, + }, + Topic: "topic", + }, + want: "color must be in the #RRGGBB form", + }, + { + name: "InvalidAndroidTitleLocArgs", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + TitleLocArgs: []string{"a1"}, + }, + }, + Topic: "topic", + }, + want: "titleLocKey is required when specifying titleLocArgs", + }, + { + name: "InvalidAndroidBodyLocArgs", + req: &Message{ + Android: &AndroidConfig{ + Notification: &AndroidNotification{ + BodyLocArgs: []string{"a1"}, + }, + }, + Topic: "topic", + }, + want: "bodyLocKey is required when specifying bodyLocArgs", + }, + { + name: "APNSMultipleAlerts", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{}, + AlertString: "alert", + }, + }, + }, + Topic: "topic", + }, + want: "multiple alert specifications", + }, + { + name: "InvalidAPNSTitleLocArgs", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{ + TitleLocArgs: []string{"a1"}, + }, + }, + }, + }, + Topic: "topic", + }, + want: "titleLocKey is required when specifying titleLocArgs", + }, + { + name: "InvalidAPNSLocArgs", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Alert: &ApsAlert{ + LocArgs: []string{"a1"}, + }, + }, + }, + }, + Topic: "topic", + }, + want: "locKey is required when specifying locArgs", + }, +} + +var invalidTopicMgtArgs = []struct { + name string + tokens []string + topic string + want string +}{ + { + name: "NoTokensAndTopic", + want: "no tokens specified", + }, + { + name: "NoTopic", + tokens: []string{"token1"}, + want: "topic name not specified", + }, + { + name: "InvalidTopicName", + tokens: []string{"token1"}, + topic: "foo*bar", + want: "invalid topic name: \"foo*bar\"", + }, + { + name: "TooManyTokens", + tokens: strings.Split("a"+strings.Repeat(",a", 1000), ","), + topic: "topic", + want: "tokens list must not contain more than 1000 items", + }, + { + name: "EmptyToken", + tokens: []string{"foo", ""}, + topic: "topic", + want: "tokens list must not contain empty strings", + }, +} + +func TestNoProjectID(t *testing.T) { + client, err := NewClient(context.Background(), &internal.MessagingConfig{}) + if client != nil || err == nil { + t.Errorf("NewClient() = (%v, %v); want = (nil, error)", client, err) + } +} + +func TestSend(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ \"name\":\"" + testMessageID + "\" }")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + + for _, tc := range validMessages { + t.Run(tc.name, func(t *testing.T) { + name, err := client.Send(ctx, tc.req) + if name != testMessageID || err != nil { + t.Errorf("Send() = (%q, %v); want = (%q, nil)", name, err, testMessageID) + } + checkFCMRequest(t, b, tr, tc.want, false) + }) + } +} + +func TestSendDryRun(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ \"name\":\"" + testMessageID + "\" }")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + + for _, tc := range validMessages { + t.Run(tc.name, func(t *testing.T) { + name, err := client.SendDryRun(ctx, tc.req) + if name != testMessageID || err != nil { + t.Errorf("SendDryRun() = (%q, %v); want = (%q, nil)", name, err, testMessageID) + } + checkFCMRequest(t, b, tr, tc.want, true) + }) + } +} + +func TestSendError(t *testing.T) { + var resp string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(resp)) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.fcmEndpoint = ts.URL + + cases := []struct { + resp string + want string + }{ + { + resp: "{}", + want: "http error status: 500; reason: server responded with an unknown error; response: {}", + }, + { + resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + }, + { + resp: "not json", + want: "http error status: 500; reason: server responded with an unknown error; response: not json", + }, + } + for _, tc := range cases { + resp = tc.resp + name, err := client.Send(ctx, &Message{Topic: "topic"}) + if err == nil || err.Error() != tc.want { + t.Errorf("Send() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + } +} + +func TestInvalidMessage(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + for _, tc := range invalidMessages { + t.Run(tc.name, func(t *testing.T) { + name, err := client.Send(ctx, tc.req) + if err == nil || err.Error() != tc.want { + t.Errorf("Send() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + }) + } +} + +func TestSubscribe(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"results\": [{}, {\"error\": \"error_reason\"}]}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + + resp, err := client.SubscribeToTopic(ctx, []string{"id1", "id2"}, "test-topic") + if err != nil { + t.Fatal(err) + } + checkIIDRequest(t, b, tr, iidSubscribe) + checkTopicMgtResponse(t, resp) +} + +func TestInvalidSubscribe(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + for _, tc := range invalidTopicMgtArgs { + t.Run(tc.name, func(t *testing.T) { + name, err := client.SubscribeToTopic(ctx, tc.tokens, tc.topic) + if err == nil || err.Error() != tc.want { + t.Errorf("SubscribeToTopic() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + }) + } +} + +func TestUnsubscribe(t *testing.T) { + var tr *http.Request + var b []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + b, _ = ioutil.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"results\": [{}, {\"error\": \"error_reason\"}]}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + + resp, err := client.UnsubscribeFromTopic(ctx, []string{"id1", "id2"}, "test-topic") + if err != nil { + t.Fatal(err) + } + checkIIDRequest(t, b, tr, iidSubscribe) + checkTopicMgtResponse(t, resp) +} + +func TestInvalidUnsubscribe(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + for _, tc := range invalidTopicMgtArgs { + t.Run(tc.name, func(t *testing.T) { + name, err := client.UnsubscribeFromTopic(ctx, tc.tokens, tc.topic) + if err == nil || err.Error() != tc.want { + t.Errorf("UnsubscribeFromTopic() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) + } + }) + } +} + +func TestTopicManagementError(t *testing.T) { + var resp string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(resp)) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + + cases := []struct { + resp string + want string + }{ + { + resp: "{}", + want: "http error status: 500; reason: client encountered an unknown error; response: {}", + }, + { + resp: "{\"error\": \"INVALID_ARGUMENT\"}", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + }, + { + resp: "not json", + want: "http error status: 500; reason: client encountered an unknown error; response: not json", + }, + } + for _, tc := range cases { + resp = tc.resp + tmr, err := client.SubscribeToTopic(ctx, []string{"id1"}, "topic") + if err == nil || err.Error() != tc.want { + t.Errorf("SubscribeToTopic() = (%q, %v); want = (%q, %q)", tmr, err, "", tc.want) + } + } + for _, tc := range cases { + resp = tc.resp + tmr, err := client.UnsubscribeFromTopic(ctx, []string{"id1"}, "topic") + if err == nil || err.Error() != tc.want { + t.Errorf("UnsubscribeFromTopic() = (%q, %v); want = (%q, %q)", tmr, err, "", tc.want) + } + } +} + +func checkFCMRequest(t *testing.T, b []byte, tr *http.Request, want map[string]interface{}, dryRun bool) { + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(parsed["message"], want) { + t.Errorf("Body = %#v; want = %#v", parsed["message"], want) + } + + validate, ok := parsed["validate_only"] + if dryRun { + if !ok || validate != true { + t.Errorf("ValidateOnly = %v; want = true", validate) + } + } else if ok { + t.Errorf("ValidateOnly = %v; want none", validate) + } + + if tr.Method != http.MethodPost { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodPost) + } + if tr.URL.Path != "/projects/test-project/messages:send" { + t.Errorf("Path = %q; want = %q", tr.URL.Path, "/projects/test-project/messages:send") + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } +} + +func checkIIDRequest(t *testing.T, b []byte, tr *http.Request, op string) { + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatal(err) + } + want := map[string]interface{}{ + "to": "/topics/test-topic", + "registration_tokens": []interface{}{"id1", "id2"}, + } + if !reflect.DeepEqual(parsed, want) { + t.Errorf("Body = %#v; want = %#v", parsed, want) + } + + if tr.Method != http.MethodPost { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodPost) + } + wantOp := "/" + op + if tr.URL.Path != wantOp { + t.Errorf("Path = %q; want = %q", tr.URL.Path, wantOp) + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } +} + +func checkTopicMgtResponse(t *testing.T, resp *TopicManagementResponse) { + if resp.SuccessCount != 1 { + t.Errorf("SuccessCount = %d; want = %d", resp.SuccessCount, 1) + } + if resp.FailureCount != 1 { + t.Errorf("FailureCount = %d; want = %d", resp.FailureCount, 1) + } + if len(resp.Errors) != 1 { + t.Fatalf("Errors = %d; want = %d", len(resp.Errors), 1) + } + e := resp.Errors[0] + if e.Index != 1 { + t.Errorf("ErrorInfo.Index = %d; want = %d", e.Index, 1) + } + if e.Reason != "unknown-error" { + t.Errorf("ErrorInfo.Reason = %s; want = %s", e.Reason, "unknown-error") + } +} diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go new file mode 100644 index 00000000..ffd4df95 --- /dev/null +++ b/messaging/messaging_utils.go @@ -0,0 +1,131 @@ +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + bareTopicNamePattern = regexp.MustCompile("^[a-zA-Z0-9-_.~%]+$") + colorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$") +) + +func validateMessage(message *Message) error { + if message == nil { + return fmt.Errorf("message must not be nil") + } + + targets := countNonEmpty(message.Token, message.Condition, message.Topic) + if targets != 1 { + return fmt.Errorf("exactly one of token, topic or condition must be specified") + } + + // validate topic + if message.Topic != "" { + bt := strings.TrimPrefix(message.Topic, "/topics/") + if !bareTopicNamePattern.MatchString(bt) { + return fmt.Errorf("malformed topic name") + } + } + + // validate AndroidConfig + if err := validateAndroidConfig(message.Android); err != nil { + return err + } + + // validate APNSConfig + return validateAPNSConfig(message.APNS) +} + +func validateAndroidConfig(config *AndroidConfig) error { + if config == nil { + return nil + } + + if config.TTL != nil && config.TTL.Seconds() < 0 { + return fmt.Errorf("ttl duration must not be negative") + } + if config.Priority != "" && config.Priority != "normal" && config.Priority != "high" { + return fmt.Errorf("priority must be 'normal' or 'high'") + } + // validate AndroidNotification + return validateAndroidNotification(config.Notification) +} + +func validateAndroidNotification(notification *AndroidNotification) error { + if notification == nil { + return nil + } + if notification.Color != "" && !colorPattern.MatchString(notification.Color) { + return fmt.Errorf("color must be in the #RRGGBB form") + } + if len(notification.TitleLocArgs) > 0 && notification.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") + } + if len(notification.BodyLocArgs) > 0 && notification.BodyLocKey == "" { + return fmt.Errorf("bodyLocKey is required when specifying bodyLocArgs") + } + return nil +} + +func validateAPNSConfig(config *APNSConfig) error { + if config != nil { + return validateAPNSPayload(config.Payload) + } + return nil +} + +func validateAPNSPayload(payload *APNSPayload) error { + if payload != nil { + return validateAps(payload.Aps) + } + return nil +} + +func validateAps(aps *Aps) error { + if aps != nil { + if aps.Alert != nil && aps.AlertString != "" { + return fmt.Errorf("multiple alert specifications") + } + return validateApsAlert(aps.Alert) + } + return nil +} + +func validateApsAlert(alert *ApsAlert) error { + if alert == nil { + return nil + } + if len(alert.TitleLocArgs) > 0 && alert.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") + } + if len(alert.LocArgs) > 0 && alert.LocKey == "" { + return fmt.Errorf("locKey is required when specifying locArgs") + } + return nil +} + +func countNonEmpty(strings ...string) int { + count := 0 + for _, s := range strings { + if s != "" { + count++ + } + } + return count +}