From 91dbe6111be130f37f03978cf4d152eb6b717b01 Mon Sep 17 00:00:00 2001 From: Cyrille Hemidy Date: Wed, 31 Jan 2018 02:10:24 +0100 Subject: [PATCH 01/16] Adding Firebase Cloud Messaging (#62) * initial commit for adding Firebase Cloud Messaging * add validator * use http const in messaging test * add client version header for stats * init integration test * add integration test (validated on IOS today) * add comment with URL to enable Firebase Cloud Messaging API * fix broken test * add integration tests * accept a Message instead of RequestMessage + and rename method + send / sendDryRun * update fcm url * rollback url endpoint * fix http constants, change responseMessage visibility, change map[string]interface{} as map[string]string * fix http constants * fix integration tests * fix APNS naming * add validators --- firebase.go | 12 + firebase_test.go | 12 + integration/messaging/messaging_test.go | 315 ++++++++++++++++++++++++ internal/internal.go | 7 + messaging/messaging.go | 291 ++++++++++++++++++++++ messaging/messaging_test.go | 117 +++++++++ 6 files changed, 754 insertions(+) create mode 100644 integration/messaging/messaging_test.go create mode 100644 messaging/messaging.go create mode 100644 messaging/messaging_test.go 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..e5c3df27 --- /dev/null +++ b/integration/messaging/messaging_test.go @@ -0,0 +1,315 @@ +package messaging + +import ( + "context" + "flag" + "fmt" + "io/ioutil" + "log" + "os" + "testing" + + "firebase.google.com/go/integration/internal" + "firebase.google.com/go/messaging" +) + +var projectID string +var client *messaging.Client + +var testFixtures = struct { + token string + topic string + condition string +}{} + +// Enable API before testing +// https://console.developers.google.com/apis/library/fcm.googleapis.com/?project= +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + log.Println("skipping Messaging integration tests in short mode.") + return + } + + token, err := ioutil.ReadFile(internal.Resource("integration_token.txt")) + if err != nil { + log.Fatalln(err) + } + testFixtures.token = string(token) + + topic, err := ioutil.ReadFile(internal.Resource("integration_topic.txt")) + if err != nil { + log.Fatalln(err) + } + testFixtures.topic = string(topic) + + condition, err := ioutil.ReadFile(internal.Resource("integration_condition.txt")) + if err != nil { + log.Fatalln(err) + } + testFixtures.condition = string(condition) + + ctx := context.Background() + app, err := internal.NewTestApp(ctx) + if err != nil { + log.Fatalln(err) + } + + projectID, err = internal.ProjectID() + if err != nil { + log.Fatalln(err) + } + + client, err = app.Messaging(ctx) + + if err != nil { + log.Fatalln(err) + } + os.Exit(m.Run()) +} + +func TestSendInvalidToken(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: "INVALID_TOKEN", + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + } + _, err := client.Send(ctx, msg) + + if err == nil { + log.Fatal(err) + } +} + +func TestSendDryRun(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + } + name, err := client.SendDryRun(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name != fmt.Sprintf("projects/%s/messages/fake_message_id", projectID) { + t.Errorf("Name : %s; want : projects/%s/messages/fake_message_id", name, projectID) + } +} + +func TestSendToToken(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendToTopic(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Topic: testFixtures.topic, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendToCondition(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Condition: testFixtures.condition, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendNotification(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendData(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Data: map[string]interface{}{ + "private_key": "foo", + "client_email": "bar@test.com", + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendAndroidNotification(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + Android: &messaging.AndroidConfig{ + CollapseKey: "Collapse", + Priority: "HIGH", + TTL: "3.5s", + Notification: &messaging.AndroidNotification{ + Title: "Android Title", + Body: "Android body", + }, + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendAndroidData(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + Android: &messaging.AndroidConfig{ + CollapseKey: "Collapse", + Priority: "HIGH", + TTL: "3.5s", + Data: map[string]string{ + "private_key": "foo", + "client_email": "bar@test.com", + }, + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendAPNSNotification(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + APNS: &messaging.APNSConfig{ + Payload: map[string]string{ + "title": "APNS Title ", + "body": "APNS bodym", + }, + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} + +func TestSendAPNSData(t *testing.T) { + ctx := context.Background() + msg := &messaging.Message{ + Token: testFixtures.token, + Notification: &messaging.Notification{ + Title: "My Title", + Body: "This is a Notification", + }, + APNS: &messaging.APNSConfig{ + Headers: map[string]string{ + "private_key": "foo", + "client_email": "bar@test.com", + }, + }, + } + name, err := client.Send(ctx, msg) + + if err != nil { + log.Fatal(err) + } + + if name == "" { + t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + } +} 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..49752eda --- /dev/null +++ b/messaging/messaging.go @@ -0,0 +1,291 @@ +// Copyright 2017 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. +package messaging + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "firebase.google.com/go/internal" + "google.golang.org/api/transport" +) + +const messagingEndpoint = "https://fcm.googleapis.com/v1" + +var errorCodes = map[int]string{ + http.StatusBadRequest: "malformed argument", + http.StatusUnauthorized: "request not authorized", + http.StatusForbidden: "project does not match or the client does not have sufficient privileges", + http.StatusNotFound: "failed to find the ...", + http.StatusConflict: "already deleted", + http.StatusTooManyRequests: "request throttled out by the backend server", + http.StatusInternalServerError: "internal server error", + http.StatusServiceUnavailable: "backend servers are over capacity", +} + +// Client is the interface for the Firebase Messaging service. +type Client struct { + // To enable testing against arbitrary endpoints. + endpoint string + client *internal.HTTPClient + project string + version string +} + +// RequestMessage is the request body message to send by Firebase Cloud Messaging Service. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send +type requestMessage struct { + ValidateOnly bool `json:"validate_only,omitempty"` + Message *Message `json:"message,omitempty"` +} + +// responseMessage is the identifier of the message sent. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages +type responseMessage struct { + Name string `json:"name"` +} + +// Message is the message to send by Firebase Cloud Messaging Service. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Message +type Message struct { + Name string `json:"name,omitempty"` + Data map[string]interface{} `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:"topic,omitempty"` + Condition string `json:"condition,omitempty"` +} + +// Notification is the Basic notification template to use across all platforms. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Notification +type Notification struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` +} + +// AndroidConfig is Android specific options for messages. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidConfig +type AndroidConfig struct { + CollapseKey string `json:"collapse_key,omitempty"` + Priority string `json:"priority,omitempty"` + TTL string `json:"ttl,omitempty"` + RestrictedPackageName string `json:"restricted_package_name,omitempty"` + Data map[string]string `json:"data,omitempty"` + Notification *AndroidNotification `json:"notification,omitempty"` +} + +// AndroidNotification is notification to send to android devices. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidNotification +type AndroidNotification struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + 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 is Webpush protocol options. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#WebpushConfig +type WebpushConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Data map[string]string `json:"data,omitempty"` + Notification *WebpushNotification `json:"notification,omitempty"` +} + +// WebpushNotification is Web notification to send via webpush protocol. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#WebpushNotification +type WebpushNotification struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Icon string `json:"icon,omitempty"` +} + +// APNSConfig is Apple Push Notification Service specific options. +// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig +type APNSConfig struct { + Headers map[string]string `json:"headers,omitempty"` + Payload map[string]string `json:"payload,omitempty"` +} + +// 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{ + endpoint: messagingEndpoint, + client: &internal.HTTPClient{Client: hc}, + project: c.ProjectID, + version: "Go/Admin/" + c.Version, + }, nil +} + +// Send sends a Message to Firebase Cloud Messaging. +// +// Send a message to specified target (a registration token, topic or condition). +// https://firebase.google.com/docs/cloud-messaging/send-message +func (c *Client) Send(ctx context.Context, message *Message) (string, error) { + if err := validateMessage(message); err != nil { + return "", err + } + payload := &requestMessage{ + Message: message, + } + return c.sendRequestMessage(ctx, payload) +} + +// SendDryRun sends a dryRun Message to Firebase Cloud Messaging. +// +// Send a message to specified target (a registration token, topic or condition). +// https://firebase.google.com/docs/cloud-messaging/send-message +func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, error) { + if err := validateMessage(message); err != nil { + return "", err + } + payload := &requestMessage{ + ValidateOnly: true, + Message: message, + } + return c.sendRequestMessage(ctx, payload) +} + +func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage) (string, error) { + versionHeader := internal.WithHeader("X-Client-Version", c.version) + + request := &internal.Request{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s/projects/%s/messages:send", c.endpoint, c.project), + Body: internal.NewJSONEntity(payload), + Opts: []internal.HTTPOption{versionHeader}, + } + resp, err := c.client.Do(ctx, request) + if err != nil { + return "", err + } + + if _, ok := errorCodes[resp.Status]; ok { + return "", fmt.Errorf("unexpected http status code : %d, reason: %v", resp.Status, string(resp.Body)) + } + + result := &responseMessage{} + err = resp.Unmarshal(http.StatusOK, result) + + return result.Name, err +} + +// validateMessage +func validateMessage(message *Message) error { + if message == nil { + return fmt.Errorf("message is empty") + } + + target := bool2int(message.Token != "") + bool2int(message.Condition != "") + bool2int(message.Topic != "") + if target != 1 { + return fmt.Errorf("Exactly one of token, topic or condition must be specified") + } + + // Validate target + if message.Topic != "" { + if strings.HasPrefix(message.Topic, "/topics/") { + return fmt.Errorf("Topic name must not contain the /topics/ prefix") + } + if !regexp.MustCompile("[a-zA-Z0-9-_.~%]+").MatchString(message.Topic) { + return fmt.Errorf("Malformed topic name") + } + } + + // validate AndroidConfig + if message.Android != nil { + if err := validateAndroidConfig(message.Android); err != nil { + return err + } + } + + return nil +} + +func validateAndroidConfig(config *AndroidConfig) error { + if config.TTL != "" && !strings.HasSuffix(config.TTL, "s") { + return fmt.Errorf("ttl must end with 's'") + } + + if _, err := time.ParseDuration(config.TTL); err != nil { + return fmt.Errorf("invalid TTL") + } + + if config.Priority != "" { + if config.Priority != "normal" && config.Priority != "high" { + return fmt.Errorf("priority must be 'normal' or 'high'") + } + } + // validate AndroidNotification + if config.Notification != nil { + if err := validateAndroidNotification(config.Notification); err != nil { + return err + } + } + return nil +} + +func validateAndroidNotification(notification *AndroidNotification) error { + if notification.Color != "" { + if !regexp.MustCompile("^#[0-9a-fA-F]{6}$").MatchString(notification.Color) { + return fmt.Errorf("color must be in the form #RRGGBB") + } + } + if len(notification.TitleLocArgs) > 0 { + if notification.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") + } + } + if len(notification.BodyLocArgs) > 0 { + if notification.BodyLocKey == "" { + return fmt.Errorf("bodyLocKey is required when specifying bodyLocArgs") + } + } + return nil +} + +func bool2int(b bool) int8 { + if b { + return 1 + } + return 0 +} diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go new file mode 100644 index 00000000..3ccd9480 --- /dev/null +++ b/messaging/messaging_test.go @@ -0,0 +1,117 @@ +package messaging + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "google.golang.org/api/option" + + "firebase.google.com/go/internal" +) + +var testMessagingConfig = &internal.MessagingConfig{ + ProjectID: "test-project", + Opts: []option.ClientOption{ + option.WithTokenSource(&internal.MockTokenSource{AccessToken: "test-token"}), + }, +} + +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 TestEmptyTarget(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + + _, err = client.Send(ctx, &Message{}) + if err == nil { + t.Errorf("SendMessage(Message{empty}) = nil; want error") + } +} + +func TestSend(t *testing.T) { + var tr *http.Request + msgName := "projects/test-project/messages/0:1500415314455276%31bd1c9631bd1c96" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ \"Name\":\"" + msgName + "\" }")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.endpoint = ts.URL + name, err := client.Send(ctx, &Message{Topic: "my-topic"}) + if err != nil { + t.Errorf("SendMessage() = %v; want nil", err) + } + + if name != msgName { + t.Errorf("response Name = %q; want = %q", name, msgName) + } + + if tr.Body == nil { + t.Fatalf("Request = nil; want non-nil") + } + 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 TestSendDryRun(t *testing.T) { + var tr *http.Request + msgName := "projects/test-project/messages/0:1500415314455276%31bd1c9631bd1c96" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{ \"Name\":\"" + msgName + "\" }")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.endpoint = ts.URL + name, err := client.SendDryRun(ctx, &Message{Topic: "my-topic"}) + if err != nil { + t.Errorf("SendMessage() = %v; want nil", err) + } + + if name != msgName { + t.Errorf("response Name = %q; want = %q", name, msgName) + } + + if tr.Body == nil { + t.Fatalf("Request = nil; want non-nil") + } + 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") + } +} From 344c4aa1e68fef386d6bdf4a9734bba12995988e Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Tue, 30 Jan 2018 19:08:34 -0800 Subject: [PATCH 02/16] Added APNS types; Updated tests --- integration/messaging/messaging_test.go | 19 ++- messaging/messaging.go | 161 ++++++++++++++------ messaging/messaging_test.go | 191 +++++++++++++++++++++--- 3 files changed, 300 insertions(+), 71 deletions(-) diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go index e5c3df27..222b68fc 100644 --- a/integration/messaging/messaging_test.go +++ b/integration/messaging/messaging_test.go @@ -8,6 +8,7 @@ import ( "log" "os" "testing" + "time" "firebase.google.com/go/integration/internal" "firebase.google.com/go/messaging" @@ -22,6 +23,8 @@ var testFixtures = struct { condition string }{} +var ttl = time.Duration(3) * time.Second + // Enable API before testing // https://console.developers.google.com/apis/library/fcm.googleapis.com/?project= func TestMain(m *testing.M) { @@ -188,7 +191,7 @@ func TestSendData(t *testing.T) { ctx := context.Background() msg := &messaging.Message{ Token: testFixtures.token, - Data: map[string]interface{}{ + Data: map[string]string{ "private_key": "foo", "client_email": "bar@test.com", }, @@ -215,7 +218,7 @@ func TestSendAndroidNotification(t *testing.T) { Android: &messaging.AndroidConfig{ CollapseKey: "Collapse", Priority: "HIGH", - TTL: "3.5s", + TTL: &ttl, Notification: &messaging.AndroidNotification{ Title: "Android Title", Body: "Android body", @@ -244,7 +247,7 @@ func TestSendAndroidData(t *testing.T) { Android: &messaging.AndroidConfig{ CollapseKey: "Collapse", Priority: "HIGH", - TTL: "3.5s", + TTL: &ttl, Data: map[string]string{ "private_key": "foo", "client_email": "bar@test.com", @@ -271,9 +274,13 @@ func TestSendAPNSNotification(t *testing.T) { Body: "This is a Notification", }, APNS: &messaging.APNSConfig{ - Payload: map[string]string{ - "title": "APNS Title ", - "body": "APNS bodym", + Payload: &messaging.APNSPayload{ + Aps: &messaging.Aps{ + Alert: &messaging.ApsAlert{ + Title: "APNS title", + Body: "APNS body", + }, + }, }, }, } diff --git a/messaging/messaging.go b/messaging/messaging.go index 49752eda..e674e08b 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -18,6 +18,7 @@ package messaging import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -39,7 +40,7 @@ var errorCodes = map[int]string{ http.StatusConflict: "already deleted", http.StatusTooManyRequests: "request throttled out by the backend server", http.StatusInternalServerError: "internal server error", - http.StatusServiceUnavailable: "backend servers are over capacity", + http.StatusServiceUnavailable: "backend server is unavailable", } // Client is the interface for the Firebase Messaging service. @@ -67,15 +68,14 @@ type responseMessage struct { // Message is the message to send by Firebase Cloud Messaging Service. // See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Message type Message struct { - Name string `json:"name,omitempty"` - Data map[string]interface{} `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:"topic,omitempty"` - Condition string `json:"condition,omitempty"` + 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:"topic,omitempty"` + Condition string `json:"condition,omitempty"` } // Notification is the Basic notification template to use across all platforms. @@ -90,12 +90,34 @@ type Notification struct { type AndroidConfig struct { CollapseKey string `json:"collapse_key,omitempty"` Priority string `json:"priority,omitempty"` - TTL string `json:"ttl,omitempty"` + TTL *time.Duration `json:"-"` RestrictedPackageName string `json:"restricted_package_name,omitempty"` Data map[string]string `json:"data,omitempty"` Notification *AndroidNotification `json:"notification,omitempty"` } +func (a *AndroidConfig) MarshalJSON() ([]byte, error) { + type androidInternal AndroidConfig + 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) + } + } + s := &struct { + TTL string `json:"ttl,omitempty"` + *androidInternal + }{ + TTL: ttl, + androidInternal: (*androidInternal)(a), + } + return json.Marshal(s) +} + // AndroidNotification is notification to send to android devices. // See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidNotification type AndroidNotification struct { @@ -132,7 +154,67 @@ type WebpushNotification struct { // See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig type APNSConfig struct { Headers map[string]string `json:"headers,omitempty"` - Payload map[string]string `json:"payload,omitempty"` + Payload *APNSPayload `json:"payload,omitempty"` +} + +type APNSPayload struct { + Aps *Aps + CustomData map[string]interface{} +} + +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) +} + +type Aps struct { + AlertString string + Alert *ApsAlert + Badge int `json:"badge,omitempty"` + Sound string `json:"sound,omitempty"` + ContentAvailable bool + Category string `json:"category,omitempty"` + ThreadID string `json:"thread-id,omitempty"` +} + +func (a *Aps) MarshalJSON() ([]byte, error) { + if a.Alert != nil && a.AlertString != "" { + return nil, fmt.Errorf("multiple alert specifications") + } + + 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 { + s.Alert = a.AlertString + } + if a.ContentAvailable { + one := 1 + s.ContentAvailable = &one + } + return json.Marshal(s) +} + +type ApsAlert struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + 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"` } // NewClient creates a new instance of the Firebase Cloud Messaging Client. @@ -162,9 +244,6 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error // Send a message to specified target (a registration token, topic or condition). // https://firebase.google.com/docs/cloud-messaging/send-message func (c *Client) Send(ctx context.Context, message *Message) (string, error) { - if err := validateMessage(message); err != nil { - return "", err - } payload := &requestMessage{ Message: message, } @@ -176,9 +255,6 @@ func (c *Client) Send(ctx context.Context, message *Message) (string, error) { // Send a message to specified target (a registration token, topic or condition). // https://firebase.google.com/docs/cloud-messaging/send-message func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, error) { - if err := validateMessage(message); err != nil { - return "", err - } payload := &requestMessage{ ValidateOnly: true, Message: message, @@ -187,13 +263,15 @@ func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, erro } func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage) (string, error) { - versionHeader := internal.WithHeader("X-Client-Version", c.version) + if err := validateMessage(payload.Message); err != nil { + return "", err + } request := &internal.Request{ Method: http.MethodPost, URL: fmt.Sprintf("%s/projects/%s/messages:send", c.endpoint, c.project), Body: internal.NewJSONEntity(payload), - Opts: []internal.HTTPOption{versionHeader}, + Opts: []internal.HTTPOption{internal.WithHeader("X-Client-Version", c.version)}, } resp, err := c.client.Do(ctx, request) if err != nil { @@ -206,7 +284,6 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage result := &responseMessage{} err = resp.Unmarshal(http.StatusOK, result) - return result.Name, err } @@ -218,67 +295,61 @@ func validateMessage(message *Message) error { target := bool2int(message.Token != "") + bool2int(message.Condition != "") + bool2int(message.Topic != "") if target != 1 { - return fmt.Errorf("Exactly one of token, topic or condition must be specified") + return fmt.Errorf("exactly one of token, topic or condition must be specified") } // Validate target if message.Topic != "" { if strings.HasPrefix(message.Topic, "/topics/") { - return fmt.Errorf("Topic name must not contain the /topics/ prefix") + return fmt.Errorf("topic name must not contain the /topics/ prefix") } if !regexp.MustCompile("[a-zA-Z0-9-_.~%]+").MatchString(message.Topic) { - return fmt.Errorf("Malformed topic name") + return fmt.Errorf("malformed topic name") } } // validate AndroidConfig - if message.Android != nil { - if err := validateAndroidConfig(message.Android); err != nil { - return err - } + if err := validateAndroidConfig(message.Android); err != nil { + return err } return nil } func validateAndroidConfig(config *AndroidConfig) error { - if config.TTL != "" && !strings.HasSuffix(config.TTL, "s") { - return fmt.Errorf("ttl must end with 's'") + if config == nil { + return nil } - if _, err := time.ParseDuration(config.TTL); err != nil { - return fmt.Errorf("invalid TTL") + if config.TTL != nil && config.TTL.Seconds() < 0 { + return fmt.Errorf("ttl duration must not be negative") } - if config.Priority != "" { if config.Priority != "normal" && config.Priority != "high" { return fmt.Errorf("priority must be 'normal' or 'high'") } } // validate AndroidNotification - if config.Notification != nil { - if err := validateAndroidNotification(config.Notification); err != nil { - return err - } + if err := validateAndroidNotification(config.Notification); err != nil { + return err } return nil } func validateAndroidNotification(notification *AndroidNotification) error { + if notification == nil { + return nil + } if notification.Color != "" { if !regexp.MustCompile("^#[0-9a-fA-F]{6}$").MatchString(notification.Color) { return fmt.Errorf("color must be in the form #RRGGBB") } } - if len(notification.TitleLocArgs) > 0 { - if notification.TitleLocKey == "" { - return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") - } + if len(notification.TitleLocArgs) > 0 && notification.TitleLocKey == "" { + return fmt.Errorf("titleLocKey is required when specifying titleLocArgs") } - if len(notification.BodyLocArgs) > 0 { - if notification.BodyLocKey == "" { - return fmt.Errorf("bodyLocKey is required when specifying bodyLocArgs") - } + if len(notification.BodyLocArgs) > 0 && notification.BodyLocKey == "" { + return fmt.Errorf("bodyLocKey is required when specifying bodyLocArgs") } return nil } diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index 3ccd9480..60050a47 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -2,9 +2,13 @@ package messaging import ( "context" + "encoding/json" + "io/ioutil" "net/http" "net/http/httptest" + "reflect" "testing" + "time" "google.golang.org/api/option" @@ -18,6 +22,144 @@ var testMessagingConfig = &internal.MessagingConfig{ }, } +var ttlWithNanos = time.Duration(1500) * time.Millisecond +var ttl = time.Duration(10) * time.Second + +var validMessages = []struct { + name string + req *Message + want map[string]interface{} +}{ + { + name: "token only", + req: &Message{Token: "test-token"}, + want: map[string]interface{}{"token": "test-token"}, + }, + { + name: "topic only", + req: &Message{Topic: "test-topic"}, + want: map[string]interface{}{"topic": "test-topic"}, + }, + { + name: "condition only", + req: &Message{Condition: "test-condition"}, + want: map[string]interface{}{"condition": "test-condition"}, + }, + { + name: "data message", + 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: "notification message", + 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: "android 1", + 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: "android 2", + 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: "android 3", + req: &Message{ + Android: &AndroidConfig{ + Priority: "high", + }, + Topic: "test-topic", + }, + want: map[string]interface{}{ + "android": map[string]interface{}{ + "priority": "high", + }, + "topic": "test-topic", + }, + }, +} + func TestNoProjectID(t *testing.T) { client, err := NewClient(context.Background(), &internal.MessagingConfig{}) if client != nil || err == nil { @@ -40,11 +182,13 @@ func TestEmptyTarget(t *testing.T) { func TestSend(t *testing.T) { var tr *http.Request - msgName := "projects/test-project/messages/0:1500415314455276%31bd1c9631bd1c96" + var b []byte + msgName := "projects/test-project/messages/msg_id" 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\":\"" + msgName + "\" }")) + w.Write([]byte("{ \"name\":\"" + msgName + "\" }")) })) defer ts.Close() @@ -54,26 +198,33 @@ func TestSend(t *testing.T) { t.Fatal(err) } client.endpoint = ts.URL - name, err := client.Send(ctx, &Message{Topic: "my-topic"}) - if err != nil { - t.Errorf("SendMessage() = %v; want nil", err) - } - if name != msgName { - t.Errorf("response Name = %q; want = %q", name, msgName) - } + for _, tc := range validMessages { + name, err := client.Send(ctx, tc.req) + if err != nil { + t.Errorf("[%s] Send() = %v; want nil", tc.name, err) + } + if name != msgName { + t.Errorf("[%s] Response = %q; want = %q", tc.name, name, msgName) + } - if tr.Body == nil { - t.Fatalf("Request = nil; want non-nil") - } - 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") + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(parsed["message"], tc.want) { + t.Errorf("[%s] Body = %v; want = %v", tc.name, parsed["message"], tc.want) + } + + if tr.Method != http.MethodPost { + t.Errorf("[%s] Method = %q; want = %q", tc.name, tr.Method, http.MethodPost) + } + if tr.URL.Path != "/projects/test-project/messages:send" { + t.Errorf("[%s] Path = %q; want = %q", tc.name, tr.URL.Path, "/projects/test-project/messages:send") + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("[%s] Authorization = %q; want = %q", tc.name, h, "Bearer test-token") + } } } From fa73c390056bd6dd1186b535c72e0685362049a8 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Tue, 30 Jan 2018 22:40:39 -0800 Subject: [PATCH 03/16] Added more tests; Fixed APNS serialization --- messaging/messaging.go | 107 ++++++---- messaging/messaging_test.go | 400 +++++++++++++++++++++++++++++++----- 2 files changed, 414 insertions(+), 93 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index e674e08b..6a6542db 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -1,4 +1,4 @@ -// Copyright 2017 Google Inc. All Rights Reserved. +// 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. @@ -52,7 +52,7 @@ type Client struct { version string } -// RequestMessage is the request body message to send by Firebase Cloud Messaging Service. +// requestMessage is the request body message to send by Firebase Cloud Messaging Service. // See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send type requestMessage struct { ValidateOnly bool `json:"validate_only,omitempty"` @@ -97,7 +97,6 @@ type AndroidConfig struct { } func (a *AndroidConfig) MarshalJSON() ([]byte, error) { - type androidInternal AndroidConfig var ttl string if a.TTL != nil { seconds := int64(*a.TTL / time.Second) @@ -108,6 +107,8 @@ func (a *AndroidConfig) MarshalJSON() ([]byte, error) { ttl = fmt.Sprintf("%ds", seconds) } } + + type androidInternal AndroidConfig s := &struct { TTL string `json:"ttl,omitempty"` *androidInternal @@ -157,6 +158,9 @@ type APNSConfig struct { Payload *APNSPayload `json:"payload,omitempty"` } +// APNSPayload is the payload object that can be included in an APNS message. +// +// The payload consists of an aps dictionary, and other custom key-value pairs. type APNSPayload struct { Aps *Aps CustomData map[string]interface{} @@ -171,20 +175,16 @@ func (p *APNSPayload) MarshalJSON() ([]byte, error) { } type Aps struct { - AlertString string - Alert *ApsAlert - Badge int `json:"badge,omitempty"` - Sound string `json:"sound,omitempty"` - ContentAvailable bool - Category string `json:"category,omitempty"` - ThreadID string `json:"thread-id,omitempty"` + 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"` } func (a *Aps) MarshalJSON() ([]byte, error) { - if a.Alert != nil && a.AlertString != "" { - return nil, fmt.Errorf("multiple alert specifications") - } - type apsAlias Aps s := &struct { Alert interface{} `json:"alert,omitempty"` @@ -206,6 +206,10 @@ func (a *Aps) MarshalJSON() ([]byte, error) { return json.Marshal(s) } +// ApsAlert is the alert payload that can be included in an APNS message. +// +// See https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html +// for supported fields. type ApsAlert struct { Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` @@ -271,26 +275,23 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage Method: http.MethodPost, URL: fmt.Sprintf("%s/projects/%s/messages:send", c.endpoint, c.project), Body: internal.NewJSONEntity(payload), - Opts: []internal.HTTPOption{internal.WithHeader("X-Client-Version", c.version)}, } resp, err := c.client.Do(ctx, request) if err != nil { return "", err } - if _, ok := errorCodes[resp.Status]; ok { - return "", fmt.Errorf("unexpected http status code : %d, reason: %v", resp.Status, string(resp.Body)) - } - result := &responseMessage{} - err = resp.Unmarshal(http.StatusOK, result) - return result.Name, err + if err := resp.Unmarshal(http.StatusOK, result); err != nil { + return "", err + } + return result.Name, nil } // validateMessage func validateMessage(message *Message) error { if message == nil { - return fmt.Errorf("message is empty") + return fmt.Errorf("message must not be nil") } target := bool2int(message.Token != "") + bool2int(message.Condition != "") + bool2int(message.Topic != "") @@ -298,12 +299,12 @@ func validateMessage(message *Message) error { return fmt.Errorf("exactly one of token, topic or condition must be specified") } - // Validate target + // Validate topic if message.Topic != "" { if strings.HasPrefix(message.Topic, "/topics/") { return fmt.Errorf("topic name must not contain the /topics/ prefix") } - if !regexp.MustCompile("[a-zA-Z0-9-_.~%]+").MatchString(message.Topic) { + if !regexp.MustCompile("^[a-zA-Z0-9-_.~%]+$").MatchString(message.Topic) { return fmt.Errorf("malformed topic name") } } @@ -313,7 +314,8 @@ func validateMessage(message *Message) error { return err } - return nil + // Validate APNSConfig + return validateAPNSConfig(message.APNS) } func validateAndroidConfig(config *AndroidConfig) error { @@ -324,26 +326,20 @@ func validateAndroidConfig(config *AndroidConfig) error { if config.TTL != nil && config.TTL.Seconds() < 0 { return fmt.Errorf("ttl duration must not be negative") } - if config.Priority != "" { - if config.Priority != "normal" && config.Priority != "high" { - return fmt.Errorf("priority must be 'normal' or 'high'") - } + if config.Priority != "" && config.Priority != "normal" && config.Priority != "high" { + return fmt.Errorf("priority must be 'normal' or 'high'") } // validate AndroidNotification - if err := validateAndroidNotification(config.Notification); err != nil { - return err - } - return nil + return validateAndroidNotification(config.Notification) } func validateAndroidNotification(notification *AndroidNotification) error { if notification == nil { return nil } - if notification.Color != "" { - if !regexp.MustCompile("^#[0-9a-fA-F]{6}$").MatchString(notification.Color) { - return fmt.Errorf("color must be in the form #RRGGBB") - } + const colorPattern = "^#[0-9a-fA-F]{6}$" + if notification.Color != "" && !regexp.MustCompile(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") @@ -354,6 +350,43 @@ func validateAndroidNotification(notification *AndroidNotification) error { 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 bool2int(b bool) int8 { if b { return 1 diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index 60050a47..f525d744 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -1,3 +1,17 @@ +// 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 ( @@ -15,6 +29,8 @@ import ( "firebase.google.com/go/internal" ) +const testMessageID = "projects/test-project/messages/msg_id" + var testMessagingConfig = &internal.MessagingConfig{ ProjectID: "test-project", Opts: []option.ClientOption{ @@ -24,6 +40,7 @@ var testMessagingConfig = &internal.MessagingConfig{ var ttlWithNanos = time.Duration(1500) * time.Millisecond var ttl = time.Duration(10) * time.Second +var invalidTTL = time.Duration(-10) * time.Second var validMessages = []struct { name string @@ -31,22 +48,22 @@ var validMessages = []struct { want map[string]interface{} }{ { - name: "token only", + name: "TokenOnly", req: &Message{Token: "test-token"}, want: map[string]interface{}{"token": "test-token"}, }, { - name: "topic only", + name: "TopicOnly", req: &Message{Topic: "test-topic"}, want: map[string]interface{}{"topic": "test-topic"}, }, { - name: "condition only", + name: "ConditionOnly", req: &Message{Condition: "test-condition"}, want: map[string]interface{}{"condition": "test-condition"}, }, { - name: "data message", + name: "DataMessage", req: &Message{ Data: map[string]string{ "k1": "v1", @@ -63,7 +80,7 @@ var validMessages = []struct { }, }, { - name: "notification message", + name: "NotificationMessage", req: &Message{ Notification: &Notification{ Title: "t", @@ -80,7 +97,7 @@ var validMessages = []struct { }, }, { - name: "android 1", + name: "AndroidDataMessage", req: &Message{ Android: &AndroidConfig{ CollapseKey: "ck", @@ -107,7 +124,7 @@ var validMessages = []struct { }, }, { - name: "android 2", + name: "AndroidNotificationMessage", req: &Message{ Android: &AndroidConfig{ RestrictedPackageName: "rpn", @@ -144,7 +161,7 @@ var validMessages = []struct { }, }, { - name: "android 3", + name: "AndroidNoTTL", req: &Message{ Android: &AndroidConfig{ Priority: "high", @@ -158,6 +175,274 @@ var validMessages = []struct { "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: "APNSAlertString", + req: &Message{ + APNS: &APNSConfig{ + Headers: map[string]string{ + "h1": "v1", + "h2": "v2", + }, + Payload: &APNSPayload{ + Aps: &Aps{ + AlertString: "a", + Badge: 42, + 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(42), + "category": "c", + "sound": "s", + "thread-id": "t", + "content-available": float64(1), + }, + "k1": "v1", + "k2": true, + }, + }, + "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: "InvalidTopicPrefix", + req: &Message{ + Topic: "/topics/foo", + }, + want: "topic name must not contain the /topics/ prefix", + }, + { + 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", + }, } func TestNoProjectID(t *testing.T) { @@ -167,28 +452,14 @@ func TestNoProjectID(t *testing.T) { } } -func TestEmptyTarget(t *testing.T) { - ctx := context.Background() - client, err := NewClient(ctx, testMessagingConfig) - if err != nil { - t.Fatal(err) - } - - _, err = client.Send(ctx, &Message{}) - if err == nil { - t.Errorf("SendMessage(Message{empty}) = nil; want error") - } -} - func TestSend(t *testing.T) { var tr *http.Request var b []byte - msgName := "projects/test-project/messages/msg_id" 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\":\"" + msgName + "\" }")) + w.Write([]byte("{ \"name\":\"" + testMessageID + "\" }")) })) defer ts.Close() @@ -200,41 +471,24 @@ func TestSend(t *testing.T) { client.endpoint = ts.URL for _, tc := range validMessages { - name, err := client.Send(ctx, tc.req) - if err != nil { - t.Errorf("[%s] Send() = %v; want nil", tc.name, err) - } - if name != msgName { - t.Errorf("[%s] Response = %q; want = %q", tc.name, name, msgName) - } - - var parsed map[string]interface{} - if err := json.Unmarshal(b, &parsed); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(parsed["message"], tc.want) { - t.Errorf("[%s] Body = %v; want = %v", tc.name, parsed["message"], tc.want) - } - - if tr.Method != http.MethodPost { - t.Errorf("[%s] Method = %q; want = %q", tc.name, tr.Method, http.MethodPost) - } - if tr.URL.Path != "/projects/test-project/messages:send" { - t.Errorf("[%s] Path = %q; want = %q", tc.name, tr.URL.Path, "/projects/test-project/messages:send") - } - if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { - t.Errorf("[%s] Authorization = %q; want = %q", tc.name, h, "Bearer test-token") - } + 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) + } + checkRequest(t, b, tr, tc.want, false) + }) } } func TestSendDryRun(t *testing.T) { var tr *http.Request - msgName := "projects/test-project/messages/0:1500415314455276%31bd1c9631bd1c96" + 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\":\"" + msgName + "\" }")) + w.Write([]byte("{ \"name\":\"" + testMessageID + "\" }")) })) defer ts.Close() @@ -244,18 +498,52 @@ func TestSendDryRun(t *testing.T) { t.Fatal(err) } client.endpoint = ts.URL - name, err := client.SendDryRun(ctx, &Message{Topic: "my-topic"}) + + 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) + } + checkRequest(t, b, tr, tc.want, true) + }) + } +} + +func TestInvalidMessage(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) if err != nil { - t.Errorf("SendMessage() = %v; want nil", err) + 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) + } + }) } +} - if name != msgName { - t.Errorf("response Name = %q; want = %q", name, msgName) +func checkRequest(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) } - if tr.Body == nil { - t.Fatalf("Request = nil; want non-nil") + 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) } From c8d0507f2bcd12c01f7f8959572c058d404ce992 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 01:33:00 -0800 Subject: [PATCH 04/16] Updated documentation --- messaging/messaging.go | 113 ++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 6a6542db..a0716b0f 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -13,11 +13,10 @@ // limitations under the License. // Package messaging contains functions for sending messages and managing -// device subscriptions with Firebase Cloud Messaging. +// device subscriptions with Firebase Cloud Messaging (FCM). package messaging import ( - "context" "encoding/json" "errors" "fmt" @@ -26,6 +25,8 @@ import ( "strings" "time" + "golang.org/x/net/context" + "firebase.google.com/go/internal" "google.golang.org/api/transport" ) @@ -43,30 +44,21 @@ var errorCodes = map[int]string{ http.StatusServiceUnavailable: "backend server is unavailable", } -// Client is the interface for the Firebase Messaging service. +// Client is the interface for the Firebase Cloud Messaging (FCM) service. type Client struct { - // To enable testing against arbitrary endpoints. - endpoint string + endpoint string // to enable testing against arbitrary endpoints client *internal.HTTPClient project string version string } -// requestMessage is the request body message to send by Firebase Cloud Messaging Service. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send -type requestMessage struct { - ValidateOnly bool `json:"validate_only,omitempty"` - Message *Message `json:"message,omitempty"` -} - -// responseMessage is the identifier of the message sent. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages -type responseMessage struct { - Name string `json:"name"` -} - -// Message is the message to send by Firebase Cloud Messaging Service. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Message +// Message represents a message that can be sent via Firebase Cloud Messaging. +// +// Message contains payload information, 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 interpret different message parameters. type Message struct { Data map[string]string `json:"data,omitempty"` Notification *Notification `json:"notification,omitempty"` @@ -78,24 +70,23 @@ type Message struct { Condition string `json:"condition,omitempty"` } -// Notification is the Basic notification template to use across all platforms. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Notification +// 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 is Android specific options for messages. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidConfig +// AndroidConfig contains Android-specific options for messages. type AndroidConfig struct { CollapseKey string `json:"collapse_key,omitempty"` - Priority string `json:"priority,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"` + 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 { @@ -119,13 +110,12 @@ func (a *AndroidConfig) MarshalJSON() ([]byte, error) { return json.Marshal(s) } -// AndroidNotification is notification to send to android devices. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidNotification +// AndroidNotification is a notification to send to android devices. type AndroidNotification struct { - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` + Title string `json:"title,omitempty"` // if specified, overrides the Title field of Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of Notification type Icon string `json:"icon,omitempty"` - Color string `json:"color,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"` @@ -135,24 +125,27 @@ type AndroidNotification struct { TitleLocArgs []string `json:"title_loc_args,omitempty"` } -// WebpushConfig is Webpush protocol options. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#WebpushConfig +// WebpushConfig contains 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 Web notification to send via webpush protocol. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#WebpushNotification +// WebpushNotification is a notification send via WebPush protocol. type WebpushNotification struct { - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` + Title string `json:"title,omitempty"` // if specified, overrides the Title field of Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of Notification type Icon string `json:"icon,omitempty"` } -// APNSConfig is Apple Push Notification Service specific options. -// See https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig +// APNSConfig contains options specified to 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 parameter values. type APNSConfig struct { Headers map[string]string `json:"headers,omitempty"` Payload *APNSPayload `json:"payload,omitempty"` @@ -160,12 +153,14 @@ type APNSConfig struct { // APNSPayload is the payload object that can be included in an APNS message. // -// The payload consists of an aps dictionary, and other custom key-value pairs. +// The payload mainly consists of the aps dictionary. Additionally it may contain arbitrary +// key-values pairs as custom data 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 { @@ -174,6 +169,10 @@ func (p *APNSPayload) MarshalJSON() ([]byte, error) { 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:"-"` @@ -184,6 +183,7 @@ type Aps struct { 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 { @@ -206,13 +206,13 @@ func (a *Aps) MarshalJSON() ([]byte, error) { return json.Marshal(s) } -// ApsAlert is the alert payload that can be included in an APNS message. +// 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"` - Body string `json:"body,omitempty"` + Title string `json:"title,omitempty"` // if specified, overrides the Title field of Notification type + Body string `json:"body,omitempty"` // if specified, overrides the Body field of Notification type LocKey string `json:"loc-key,omitempty"` LocArgs []string `json:"loc-args,omitempty"` TitleLocKey string `json:"title-loc-key,omitempty"` @@ -224,7 +224,7 @@ type ApsAlert struct { // 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. +// 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") @@ -243,10 +243,20 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error }, nil } +type requestMessage struct { + ValidateOnly bool `json:"validate_only,omitempty"` + Message *Message `json:"message,omitempty"` +} + +type responseMessage struct { + Name string `json:"name"` +} + // Send sends a Message to Firebase Cloud Messaging. // -// Send a message to specified target (a registration token, topic or condition). -// https://firebase.google.com/docs/cloud-messaging/send-message +// The Message must specify exactly one of Token, Topic and Condition fields. FCM will +// customize the message for each target platform based on the parameters specified within the +// Message. func (c *Client) Send(ctx context.Context, message *Message) (string, error) { payload := &requestMessage{ Message: message, @@ -254,10 +264,10 @@ func (c *Client) Send(ctx context.Context, message *Message) (string, error) { return c.sendRequestMessage(ctx, payload) } -// SendDryRun sends a dryRun Message to Firebase Cloud Messaging. +// SendDryRun sends a Message to Firebase Cloud Messaging in the dry run (validation only) mode. // -// Send a message to specified target (a registration token, topic or condition). -// https://firebase.google.com/docs/cloud-messaging/send-message +// This function does not actually delivery 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 := &requestMessage{ ValidateOnly: true, @@ -288,7 +298,6 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage return result.Name, nil } -// validateMessage func validateMessage(message *Message) error { if message == nil { return fmt.Errorf("message must not be nil") @@ -299,7 +308,7 @@ func validateMessage(message *Message) error { return fmt.Errorf("exactly one of token, topic or condition must be specified") } - // Validate topic + // validate topic if message.Topic != "" { if strings.HasPrefix(message.Topic, "/topics/") { return fmt.Errorf("topic name must not contain the /topics/ prefix") @@ -314,7 +323,7 @@ func validateMessage(message *Message) error { return err } - // Validate APNSConfig + // validate APNSConfig return validateAPNSConfig(message.APNS) } From c611b3c83a9bd9fa47af455d9dac31dae0182d64 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 13:22:47 -0800 Subject: [PATCH 05/16] Improved error handling inFCM --- messaging/messaging.go | 203 +++++++++++------------------------- messaging/messaging_test.go | 42 ++++++++ 2 files changed, 101 insertions(+), 144 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index a0716b0f..859dedf6 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -21,8 +21,6 @@ import ( "errors" "fmt" "net/http" - "regexp" - "strings" "time" "golang.org/x/net/context" @@ -33,15 +31,15 @@ import ( const messagingEndpoint = "https://fcm.googleapis.com/v1" -var errorCodes = map[int]string{ - http.StatusBadRequest: "malformed argument", - http.StatusUnauthorized: "request not authorized", - http.StatusForbidden: "project does not match or the client does not have sufficient privileges", - http.StatusNotFound: "failed to find the ...", - http.StatusConflict: "already deleted", - http.StatusTooManyRequests: "request throttled out by the backend server", - http.StatusInternalServerError: "internal server error", - http.StatusServiceUnavailable: "backend server is unavailable", +const unknownError = "unknown-error" + +var fcmErrorCodes = map[string]string{ + "INVALID_ARGUMENT": "invalid-argument", + "NOT_FOUND": "registration-token-not-registered", + "PERMISSION_DENIED": "authentication-error", + "RESOURCE_EXHAUSTED": "message-rate-exceeded", + "UNAUTHENTICATED": "authentication-error", + "UNAVAILABLE": "server-unavailable", } // Client is the interface for the Firebase Cloud Messaging (FCM) service. @@ -52,13 +50,13 @@ type Client struct { version string } -// Message represents a message that can be sent via Firebase Cloud Messaging. +// Message to be sent via Firebase Cloud Messaging. // -// Message contains payload information, recipient information and platform-specific configuration +// 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 interpret different message parameters. +// 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"` @@ -76,7 +74,7 @@ type Notification struct { Body string `json:"body,omitempty"` } -// AndroidConfig contains Android-specific options for messages. +// 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" @@ -110,10 +108,10 @@ func (a *AndroidConfig) MarshalJSON() ([]byte, error) { return json.Marshal(s) } -// AndroidNotification is a notification to send to android devices. +// AndroidNotification is a notification to send to Android devices. type AndroidNotification struct { - Title string `json:"title,omitempty"` // if specified, overrides the Title field of Notification type - Body string `json:"body,omitempty"` // if specified, overrides the Body field of Notification type + 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"` @@ -125,7 +123,7 @@ type AndroidNotification struct { TitleLocArgs []string `json:"title_loc_args,omitempty"` } -// WebpushConfig contains options specific to the WebPush protocol. +// WebpushConfig contains messaging options specific to the WebPush protocol. // // See https://tools.ietf.org/html/rfc8030#section-5 for additional details, and supported // headers. @@ -135,26 +133,29 @@ type WebpushConfig struct { Notification *WebpushNotification `json:"notification,omitempty"` } -// WebpushNotification is a notification send via WebPush protocol. +// WebpushNotification is a notification to send via WebPush protocol. type WebpushNotification struct { - Title string `json:"title,omitempty"` // if specified, overrides the Title field of Notification type - Body string `json:"body,omitempty"` // if specified, overrides the Body field of Notification type + 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 options specified to Apple Push Notification Service (APNS). +// 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 parameter values. +// 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 object that can be included in an APNS message. +// 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{} @@ -211,8 +212,8 @@ func (a *Aps) MarshalJSON() ([]byte, error) { // 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 Notification type - Body string `json:"body,omitempty"` // if specified, overrides the Body field of Notification type + 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"` @@ -243,19 +244,10 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error }, nil } -type requestMessage struct { - ValidateOnly bool `json:"validate_only,omitempty"` - Message *Message `json:"message,omitempty"` -} - -type responseMessage struct { - Name string `json:"name"` -} - // 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 parameters specified within the +// customize the message for each target platform based on the parameters specified in the // Message. func (c *Client) Send(ctx context.Context, message *Message) (string, error) { payload := &requestMessage{ @@ -266,7 +258,7 @@ func (c *Client) Send(ctx context.Context, message *Message) (string, error) { // SendDryRun sends a Message to Firebase Cloud Messaging in the dry run (validation only) mode. // -// This function does not actually delivery the message to target devices. Instead, it performs all +// 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 := &requestMessage{ @@ -276,6 +268,22 @@ func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, erro return c.sendRequestMessage(ctx, payload) } +type requestMessage struct { + ValidateOnly bool `json:"validate_only,omitempty"` + Message *Message `json:"message,omitempty"` +} + +type responseMessage struct { + Name string `json:"name"` +} + +type fcmError struct { + Error struct { + Status string `json:"status"` + Message string `json:"message"` + } `json:"error"` +} + func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage) (string, error) { if err := validateMessage(payload.Message); err != nil { return "", err @@ -291,114 +299,21 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage return "", err } - result := &responseMessage{} - if err := resp.Unmarshal(http.StatusOK, result); err != nil { - return "", err - } - return result.Name, nil -} - -func validateMessage(message *Message) error { - if message == nil { - return fmt.Errorf("message must not be nil") - } - - target := bool2int(message.Token != "") + bool2int(message.Condition != "") + bool2int(message.Topic != "") - if target != 1 { - return fmt.Errorf("exactly one of token, topic or condition must be specified") + if resp.Status == http.StatusOK { + var result responseMessage + err := json.Unmarshal(resp.Body, &result) + return result.Name, err } - // validate topic - if message.Topic != "" { - if strings.HasPrefix(message.Topic, "/topics/") { - return fmt.Errorf("topic name must not contain the /topics/ prefix") - } - if !regexp.MustCompile("^[a-zA-Z0-9-_.~%]+$").MatchString(message.Topic) { - return fmt.Errorf("malformed topic name") - } + var fe fcmError + json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level + code := fcmErrorCodes[fe.Error.Status] + msg := fe.Error.Message + if code == "" { + code = unknownError } - - // 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 - } - const colorPattern = "^#[0-9a-fA-F]{6}$" - if notification.Color != "" && !regexp.MustCompile(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 bool2int(b bool) int8 { - if b { - return 1 + if msg == "" { + msg = fmt.Sprintf("http error status: %d; body: %s", resp.Status, string(resp.Body)) } - return 0 + return "", fmt.Errorf("%s; code: %s", msg, code) } diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index f525d744..189d94ca 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -510,6 +510,48 @@ func TestSendDryRun(t *testing.T) { } } +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.endpoint = ts.URL + + cases := []struct { + resp string + want string + }{ + { + resp: "{}", + want: "http error status: 500; body: {}; code: unknown-error", + }, + { + resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", + want: "test error; code: invalid-argument", + }, + { + resp: "not json", + want: "http error status: 500; body: not json; code: unknown-error", + }, + } + 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) From 54306efab0b3bd8048f409df14c5c8134b8e6b91 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 13:34:04 -0800 Subject: [PATCH 06/16] Added utils file --- messaging/messaging_utils.go | 126 +++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 messaging/messaging_utils.go diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go new file mode 100644 index 00000000..84253c3d --- /dev/null +++ b/messaging/messaging_utils.go @@ -0,0 +1,126 @@ +// 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" +) + +func validateMessage(message *Message) error { + if message == nil { + return fmt.Errorf("message must not be nil") + } + + target := bool2int(message.Token != "") + bool2int(message.Condition != "") + bool2int(message.Topic != "") + if target != 1 { + return fmt.Errorf("exactly one of token, topic or condition must be specified") + } + + // validate topic + if message.Topic != "" { + if strings.HasPrefix(message.Topic, "/topics/") { + return fmt.Errorf("topic name must not contain the /topics/ prefix") + } + if !regexp.MustCompile("^[a-zA-Z0-9-_.~%]+$").MatchString(message.Topic) { + 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 + } + const colorPattern = "^#[0-9a-fA-F]{6}$" + if notification.Color != "" && !regexp.MustCompile(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 bool2int(b bool) int8 { + if b { + return 1 + } + return 0 +} From 12deb834afe5042706a1004efbe9401fdbf29c65 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 14:07:32 -0800 Subject: [PATCH 07/16] Updated integration tests --- integration/messaging/messaging_test.go | 286 +++--------------------- messaging/messaging.go | 27 +-- messaging/messaging_test.go | 6 +- 3 files changed, 50 insertions(+), 269 deletions(-) diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go index 222b68fc..87965505 100644 --- a/integration/messaging/messaging_test.go +++ b/integration/messaging/messaging_test.go @@ -1,12 +1,25 @@ +// 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" "flag" - "fmt" - "io/ioutil" "log" "os" + "regexp" "testing" "time" @@ -14,7 +27,6 @@ import ( "firebase.google.com/go/messaging" ) -var projectID string var client *messaging.Client var testFixtures = struct { @@ -34,289 +46,65 @@ func TestMain(m *testing.M) { return } - token, err := ioutil.ReadFile(internal.Resource("integration_token.txt")) - if err != nil { - log.Fatalln(err) - } - testFixtures.token = string(token) - - topic, err := ioutil.ReadFile(internal.Resource("integration_topic.txt")) - if err != nil { - log.Fatalln(err) - } - testFixtures.topic = string(topic) - - condition, err := ioutil.ReadFile(internal.Resource("integration_condition.txt")) - if err != nil { - log.Fatalln(err) - } - testFixtures.condition = string(condition) - ctx := context.Background() app, err := internal.NewTestApp(ctx) if err != nil { log.Fatalln(err) } - projectID, err = internal.ProjectID() - if err != nil { - log.Fatalln(err) - } - client, err = app.Messaging(ctx) - if err != nil { log.Fatalln(err) } os.Exit(m.Run()) } -func TestSendInvalidToken(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: "INVALID_TOKEN", - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - } - _, err := client.Send(ctx, msg) - - if err == nil { - log.Fatal(err) - } -} - -func TestSendDryRun(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - } - name, err := client.SendDryRun(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name != fmt.Sprintf("projects/%s/messages/fake_message_id", projectID) { - t.Errorf("Name : %s; want : projects/%s/messages/fake_message_id", name, projectID) - } -} - -func TestSendToToken(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendToTopic(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Topic: testFixtures.topic, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendToCondition(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Condition: testFixtures.condition, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendNotification(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendData(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Data: map[string]string{ - "private_key": "foo", - "client_email": "bar@test.com", - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendAndroidNotification(t *testing.T) { +func TestSend(t *testing.T) { ctx := context.Background() msg := &messaging.Message{ - Token: testFixtures.token, + Topic: "foo-bar", Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", + Title: "Title", + Body: "Body", }, Android: &messaging.AndroidConfig{ - CollapseKey: "Collapse", - Priority: "HIGH", - TTL: &ttl, Notification: &messaging.AndroidNotification{ Title: "Android Title", - Body: "Android body", + Body: "Android Body", }, }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendAndroidData(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - Android: &messaging.AndroidConfig{ - CollapseKey: "Collapse", - Priority: "HIGH", - TTL: &ttl, - Data: map[string]string{ - "private_key": "foo", - "client_email": "bar@test.com", - }, - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) - } -} - -func TestSendAPNSNotification(t *testing.T) { - ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, APNS: &messaging.APNSConfig{ Payload: &messaging.APNSPayload{ Aps: &messaging.Aps{ Alert: &messaging.ApsAlert{ - Title: "APNS title", - Body: "APNS body", + Title: "APNS Title", + Body: "APNS Body", }, }, }, }, + Webpush: &messaging.WebpushConfig{ + Notification: &messaging.WebpushNotification{ + Title: "Webpush Title", + Body: "Webpush Body", + }, + }, } - name, err := client.Send(ctx, msg) - + name, err := client.SendDryRun(ctx, msg) if err != nil { log.Fatal(err) } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + const pattern = "^projects/.*/messages/.*$" + if !regexp.MustCompile(pattern).MatchString(name) { + t.Errorf("Send() = %q; want = %q", name, pattern) } } -func TestSendAPNSData(t *testing.T) { +func TestSendInvalidToken(t *testing.T) { ctx := context.Background() - msg := &messaging.Message{ - Token: testFixtures.token, - Notification: &messaging.Notification{ - Title: "My Title", - Body: "This is a Notification", - }, - APNS: &messaging.APNSConfig{ - Headers: map[string]string{ - "private_key": "foo", - "client_email": "bar@test.com", - }, - }, - } - name, err := client.Send(ctx, msg) - - if err != nil { - log.Fatal(err) - } - - if name == "" { - t.Errorf("Name : %s; want : projects/%s/messages/#id#", name, projectID) + msg := &messaging.Message{Token: "INVALID_TOKEN"} + _, err := client.Send(ctx, msg) + if err == nil { + t.Errorf("Send() = nil; want error") } } diff --git a/messaging/messaging.go b/messaging/messaging.go index 859dedf6..4ac5cfe4 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -31,15 +31,13 @@ import ( const messagingEndpoint = "https://fcm.googleapis.com/v1" -const unknownError = "unknown-error" - var fcmErrorCodes = map[string]string{ - "INVALID_ARGUMENT": "invalid-argument", - "NOT_FOUND": "registration-token-not-registered", - "PERMISSION_DENIED": "authentication-error", - "RESOURCE_EXHAUSTED": "message-rate-exceeded", - "UNAUTHENTICATED": "authentication-error", - "UNAVAILABLE": "server-unavailable", + "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", + "NOT_FOUND": "specified registration token not found; code: registration-token-not-registered", + "PERMISSION_DENIED": "client does not have permission to perform the requested operation; code: authentication-error", + "RESOURCE_EXHAUSTED": "messaging service quota exceeded; code: message-rate-exceeded", + "UNAUTHENTICATED": "client failed to authenticate; code: authentication-error", + "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", } // Client is the interface for the Firebase Cloud Messaging (FCM) service. @@ -279,8 +277,7 @@ type responseMessage struct { type fcmError struct { Error struct { - Status string `json:"status"` - Message string `json:"message"` + Status string `json:"status"` } `json:"error"` } @@ -307,13 +304,9 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage var fe fcmError json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level - code := fcmErrorCodes[fe.Error.Status] - msg := fe.Error.Message - if code == "" { - code = unknownError - } + msg := fcmErrorCodes[fe.Error.Status] if msg == "" { - msg = fmt.Sprintf("http error status: %d; body: %s", resp.Status, string(resp.Body)) + msg = fmt.Sprintf("client encounterd an unknown error; response: %s", string(resp.Body)) } - return "", fmt.Errorf("%s; code: %s", msg, code) + return "", fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) } diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index 189d94ca..447a0e5f 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -532,15 +532,15 @@ func TestSendError(t *testing.T) { }{ { resp: "{}", - want: "http error status: 500; body: {}; code: unknown-error", + want: "http error status: 500; reason: client encounterd an unknown error; response: {}", }, { resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", - want: "test error; code: invalid-argument", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", }, { resp: "not json", - want: "http error status: 500; body: not json; code: unknown-error", + want: "http error status: 500; reason: client encounterd an unknown error; response: not json", }, } for _, tc := range cases { From f7e7a36e717c7d7066e8bede9282f52d970d9be7 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 15:40:53 -0800 Subject: [PATCH 08/16] Implemented topic management operations --- messaging/messaging.go | 182 +++++++++++++++++++++++++---- messaging/messaging_test.go | 225 +++++++++++++++++++++++++++++++++++- 2 files changed, 381 insertions(+), 26 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 4ac5cfe4..4e1855c8 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" "net/http" + "regexp" + "strings" "time" "golang.org/x/net/context" @@ -30,22 +32,33 @@ import ( ) const messagingEndpoint = "https://fcm.googleapis.com/v1" +const iidEndpoint = "https://iid.googleapis.com" +const iidSubscribe = "iid/v1:batchAdd" +const iidUnsubscribe = "iid/v1:batchRemove" var fcmErrorCodes = map[string]string{ "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", - "NOT_FOUND": "specified registration token not found; code: registration-token-not-registered", + "NOT_FOUND": "request contains an invalid argument; code: registration-token-not-registered", "PERMISSION_DENIED": "client does not have permission to perform the requested operation; code: authentication-error", "RESOURCE_EXHAUSTED": "messaging service quota exceeded; code: message-rate-exceeded", "UNAUTHENTICATED": "client failed to authenticate; code: authentication-error", "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", } +var 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 encounterd 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 { - endpoint string // to enable testing against arbitrary endpoints - client *internal.HTTPClient - project string - version string + 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. @@ -220,6 +233,44 @@ type ApsAlert struct { 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 @@ -235,10 +286,11 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error } return &Client{ - endpoint: messagingEndpoint, - client: &internal.HTTPClient{Client: hc}, - project: c.ProjectID, - version: "Go/Admin/" + c.Version, + fcmEndpoint: messagingEndpoint, + iidEndpoint: iidEndpoint, + client: &internal.HTTPClient{Client: hc}, + project: c.ProjectID, + version: "Go/Admin/" + c.Version, }, nil } @@ -248,10 +300,10 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error // customize the message for each target platform based on the parameters specified in the // Message. func (c *Client) Send(ctx context.Context, message *Message) (string, error) { - payload := &requestMessage{ + payload := &fcmRequest{ Message: message, } - return c.sendRequestMessage(ctx, payload) + return c.makeSendRequest(ctx, payload) } // SendDryRun sends a Message to Firebase Cloud Messaging in the dry run (validation only) mode. @@ -259,19 +311,43 @@ func (c *Client) Send(ctx context.Context, message *Message) (string, error) { // 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 := &requestMessage{ + payload := &fcmRequest{ ValidateOnly: true, Message: message, } - return c.sendRequestMessage(ctx, payload) + return c.makeSendRequest(ctx, payload) } -type requestMessage struct { +// SubscribeToTopic subscribes a list of registration tokens to a topic. +// +// The tokens list must not be empty, with 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, with 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 responseMessage struct { +type fcmResponse struct { Name string `json:"name"` } @@ -281,15 +357,29 @@ type fcmError struct { } `json:"error"` } -func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage) (string, error) { - if err := validateMessage(payload.Message); err != nil { +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.endpoint, c.project), - Body: internal.NewJSONEntity(payload), + 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 { @@ -297,7 +387,7 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage } if resp.Status == http.StatusOK { - var result responseMessage + var result fcmResponse err := json.Unmarshal(resp.Body, &result) return result.Name, err } @@ -310,3 +400,55 @@ func (c *Client) sendRequestMessage(ctx context.Context, payload *requestMessage } return "", fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) } + +func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest) (*TopicManagementResponse, error) { + if req.Tokens == nil { + 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 !regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$").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 encounterd 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 index 447a0e5f..33e4df19 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -21,6 +21,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "testing" "time" @@ -445,6 +446,41 @@ var invalidMessages = []struct { }, } +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 { @@ -468,7 +504,7 @@ func TestSend(t *testing.T) { if err != nil { t.Fatal(err) } - client.endpoint = ts.URL + client.fcmEndpoint = ts.URL for _, tc := range validMessages { t.Run(tc.name, func(t *testing.T) { @@ -476,7 +512,7 @@ func TestSend(t *testing.T) { if name != testMessageID || err != nil { t.Errorf("Send() = (%q, %v); want = (%q, nil)", name, err, testMessageID) } - checkRequest(t, b, tr, tc.want, false) + checkFCMRequest(t, b, tr, tc.want, false) }) } } @@ -497,7 +533,7 @@ func TestSendDryRun(t *testing.T) { if err != nil { t.Fatal(err) } - client.endpoint = ts.URL + client.fcmEndpoint = ts.URL for _, tc := range validMessages { t.Run(tc.name, func(t *testing.T) { @@ -505,7 +541,7 @@ func TestSendDryRun(t *testing.T) { if name != testMessageID || err != nil { t.Errorf("SendDryRun() = (%q, %v); want = (%q, nil)", name, err, testMessageID) } - checkRequest(t, b, tr, tc.want, true) + checkFCMRequest(t, b, tr, tc.want, true) }) } } @@ -524,7 +560,7 @@ func TestSendError(t *testing.T) { if err != nil { t.Fatal(err) } - client.endpoint = ts.URL + client.fcmEndpoint = ts.URL cases := []struct { resp string @@ -568,7 +604,140 @@ func TestInvalidMessage(t *testing.T) { } } -func checkRequest(t *testing.T, b []byte, tr *http.Request, want map[string]interface{}, dryRun bool) { +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 encounterd 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 encounterd 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) @@ -596,3 +765,47 @@ func checkRequest(t *testing.T, b []byte, tr *http.Request, want map[string]inte 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") + } +} From 6779b619cae79d95f49e86c3979fae5509c11bb2 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 16:02:55 -0800 Subject: [PATCH 09/16] Added integration tests --- integration/messaging/messaging_test.go | 32 +++++++++++++++++++++---- messaging/messaging_test.go | 18 ++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go index 87965505..e10325ba 100644 --- a/integration/messaging/messaging_test.go +++ b/integration/messaging/messaging_test.go @@ -15,7 +15,6 @@ package messaging import ( - "context" "flag" "log" "os" @@ -23,10 +22,15 @@ import ( "testing" "time" + "golang.org/x/net/context" + "firebase.google.com/go/integration/internal" "firebase.google.com/go/messaging" ) +const testRegistrationToken = "fGw0qy4TGgk:APA91bGtWGjuhp4WRhHXgbabIYp1jxEKI08ofj_v1bKhWAGJQ4e3arRCWzeTfHaLz83mBnDh0a" + + "PWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE" + var client *messaging.Client var testFixtures = struct { @@ -60,7 +64,6 @@ func TestMain(m *testing.M) { } func TestSend(t *testing.T) { - ctx := context.Background() msg := &messaging.Message{ Topic: "foo-bar", Notification: &messaging.Notification{ @@ -90,7 +93,7 @@ func TestSend(t *testing.T) { }, }, } - name, err := client.SendDryRun(ctx, msg) + name, err := client.SendDryRun(context.Background(), msg) if err != nil { log.Fatal(err) } @@ -101,10 +104,29 @@ func TestSend(t *testing.T) { } func TestSendInvalidToken(t *testing.T) { - ctx := context.Background() msg := &messaging.Message{Token: "INVALID_TOKEN"} - _, err := client.Send(ctx, msg) + _, err := client.Send(context.Background(), msg) if 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/messaging/messaging_test.go b/messaging/messaging_test.go index 33e4df19..882be43d 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -205,6 +205,24 @@ var validMessages = []struct { "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{ From 8df6499855ecdbcd168c7c3a0e144d82c5186ded Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 31 Jan 2018 16:17:20 -0800 Subject: [PATCH 10/16] Updated CHANGELOG --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee0af8b..7e002d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased -- + +- [feature] Added the `messaging` package for sending Firebase notifications + and managing topic subscriptions. # v2.4.0 From 6cdc7c51bce38d7b5066a306a023c355f765d092 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Thu, 1 Feb 2018 11:08:19 -0800 Subject: [PATCH 11/16] Addressing code review comments --- integration/messaging/messaging_test.go | 34 +++++++------- messaging/messaging.go | 62 ++++++++++++++----------- messaging/messaging_test.go | 8 ++-- messaging/messaging_utils.go | 10 ++-- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go index e10325ba..b04a8adb 100644 --- a/integration/messaging/messaging_test.go +++ b/integration/messaging/messaging_test.go @@ -28,25 +28,28 @@ import ( "firebase.google.com/go/messaging" ) -const testRegistrationToken = "fGw0qy4TGgk:APA91bGtWGjuhp4WRhHXgbabIYp1jxEKI08ofj_v1bKhWAGJQ4e3arRCWzeTfHaLz83mBnDh0a" + - "PWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE" +// 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 - -var testFixtures = struct { - token string - topic string - condition string -}{} - -var ttl = time.Duration(3) * time.Second +var ( + client *messaging.Client + testFixtures = struct { + token string + topic string + condition string + }{} + ttl = time.Duration(3) * time.Second +) // Enable API before testing -// https://console.developers.google.com/apis/library/fcm.googleapis.com/?project= +// 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.") + log.Println("Skipping messaging integration tests in short mode.") return } @@ -95,7 +98,7 @@ func TestSend(t *testing.T) { } name, err := client.SendDryRun(context.Background(), msg) if err != nil { - log.Fatal(err) + log.Fatalln(err) } const pattern = "^projects/.*/messages/.*$" if !regexp.MustCompile(pattern).MatchString(name) { @@ -105,8 +108,7 @@ func TestSend(t *testing.T) { func TestSendInvalidToken(t *testing.T) { msg := &messaging.Message{Token: "INVALID_TOKEN"} - _, err := client.Send(context.Background(), msg) - if err == nil { + if _, err := client.Send(context.Background(), msg); err == nil { t.Errorf("Send() = nil; want error") } } diff --git a/messaging/messaging.go b/messaging/messaging.go index 4e1855c8..34249925 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -31,26 +31,32 @@ import ( "google.golang.org/api/transport" ) -const messagingEndpoint = "https://fcm.googleapis.com/v1" -const iidEndpoint = "https://iid.googleapis.com" -const iidSubscribe = "iid/v1:batchAdd" -const iidUnsubscribe = "iid/v1:batchRemove" - -var fcmErrorCodes = 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", - "PERMISSION_DENIED": "client does not have permission to perform the requested operation; code: authentication-error", - "RESOURCE_EXHAUSTED": "messaging service quota exceeded; code: message-rate-exceeded", - "UNAUTHENTICATED": "client failed to authenticate; code: authentication-error", - "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", -} +const ( + messagingEndpoint = "https://fcm.googleapis.com/v1" + iidEndpoint = "https://iid.googleapis.com" + iidSubscribe = "iid/v1:batchAdd" + iidUnsubscribe = "iid/v1:batchRemove" +) -var 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 encounterd an internal error; code: internal-error", - "TOO_MANY_TOPICS": "client exceeded the number of allowed topics; code: too-many-topics", -} +var ( + topicNamePattern = regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$") + + fcmErrorCodes = 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", + "PERMISSION_DENIED": "client does not have permission to perform the requested operation; code: authentication-error", + "RESOURCE_EXHAUSTED": "messaging service quota exceeded; code: message-rate-exceeded", + "UNAUTHENTICATED": "client failed to authenticate; code: authentication-error", + "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", + } + + 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 { @@ -85,7 +91,7 @@ type Notification struct { Body string `json:"body,omitempty"` } -// AndroidConfig contains messaging options specific to the Android platform.. +// 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" @@ -257,7 +263,7 @@ func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse { tmr.SuccessCount++ } else { tmr.FailureCount++ - code, _ := res["error"].(string) + code := res["error"].(string) reason := iidErrorCodes[code] if reason == "" { reason = "unknown-error" @@ -277,7 +283,7 @@ func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse { // 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") + return nil, errors.New("project ID is required to access Firebase Cloud Messaging client") } hc, _, err := transport.NewHTTPClient(ctx, c.Opts...) @@ -297,7 +303,7 @@ func NewClient(ctx context.Context, c *internal.MessagingConfig) (*Client, error // 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 parameters specified in the +// 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{ @@ -320,7 +326,7 @@ func (c *Client) SendDryRun(ctx context.Context, message *Message) (string, erro // SubscribeToTopic subscribes a list of registration tokens to a topic. // -// The tokens list must not be empty, with at most 1000 tokens. +// 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, @@ -332,7 +338,7 @@ func (c *Client) SubscribeToTopic(ctx context.Context, tokens []string, topic st // UnsubscribeFromTopic unsubscribes a list of registration tokens from a topic. // -// The tokens list must not be empty, with at most 1000 tokens. +// 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, @@ -396,7 +402,7 @@ func (c *Client) makeSendRequest(ctx context.Context, req *fcmRequest) (string, json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level msg := fcmErrorCodes[fe.Error.Status] if msg == "" { - msg = fmt.Sprintf("client encounterd an unknown error; response: %s", string(resp.Body)) + msg = fmt.Sprintf("client encountered an unknown error; response: %s", string(resp.Body)) } return "", fmt.Errorf("http error status: %d; reason: %s", resp.Status, msg) } @@ -417,7 +423,7 @@ func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest if req.Topic == "" { return nil, fmt.Errorf("topic name not specified") } - if !regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$").MatchString(req.Topic) { + if !topicNamePattern.MatchString(req.Topic) { return nil, fmt.Errorf("invalid topic name: %q", req.Topic) } @@ -448,7 +454,7 @@ func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest json.Unmarshal(resp.Body, &ie) // ignore any json parse errors at this level msg := iidErrorCodes[ie.Error] if msg == "" { - msg = fmt.Sprintf("client encounterd an unknown error; response: %s", string(resp.Body)) + 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 index 882be43d..ed9ccb36 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -586,7 +586,7 @@ func TestSendError(t *testing.T) { }{ { resp: "{}", - want: "http error status: 500; reason: client encounterd an unknown error; response: {}", + want: "http error status: 500; reason: client encountered an unknown error; response: {}", }, { resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", @@ -594,7 +594,7 @@ func TestSendError(t *testing.T) { }, { resp: "not json", - want: "http error status: 500; reason: client encounterd an unknown error; response: not json", + want: "http error status: 500; reason: client encountered an unknown error; response: not json", }, } for _, tc := range cases { @@ -728,7 +728,7 @@ func TestTopicManagementError(t *testing.T) { }{ { resp: "{}", - want: "http error status: 500; reason: client encounterd an unknown error; response: {}", + want: "http error status: 500; reason: client encountered an unknown error; response: {}", }, { resp: "{\"error\": \"INVALID_ARGUMENT\"}", @@ -736,7 +736,7 @@ func TestTopicManagementError(t *testing.T) { }, { resp: "not json", - want: "http error status: 500; reason: client encounterd an unknown error; response: not json", + want: "http error status: 500; reason: client encountered an unknown error; response: not json", }, } for _, tc := range cases { diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go index 84253c3d..75ed0406 100644 --- a/messaging/messaging_utils.go +++ b/messaging/messaging_utils.go @@ -20,6 +20,11 @@ import ( "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") @@ -35,7 +40,7 @@ func validateMessage(message *Message) error { if strings.HasPrefix(message.Topic, "/topics/") { return fmt.Errorf("topic name must not contain the /topics/ prefix") } - if !regexp.MustCompile("^[a-zA-Z0-9-_.~%]+$").MatchString(message.Topic) { + if !bareTopicNamePattern.MatchString(message.Topic) { return fmt.Errorf("malformed topic name") } } @@ -68,8 +73,7 @@ func validateAndroidNotification(notification *AndroidNotification) error { if notification == nil { return nil } - const colorPattern = "^#[0-9a-fA-F]{6}$" - if notification.Color != "" && !regexp.MustCompile(colorPattern).MatchString(notification.Color) { + if notification.Color != "" && !colorPattern.MatchString(notification.Color) { return fmt.Errorf("color must be in the #RRGGBB form") } if len(notification.TitleLocArgs) > 0 && notification.TitleLocKey == "" { From 02f63fb01382c4533266b040da4e97bd6a3eaf7d Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Thu, 1 Feb 2018 13:16:11 -0800 Subject: [PATCH 12/16] Supporting 0 valued Aps.Badge --- messaging/messaging.go | 4 +-- messaging/messaging_test.go | 60 +++++++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 34249925..50c74963 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -194,7 +194,7 @@ func (p *APNSPayload) MarshalJSON() ([]byte, error) { type Aps struct { AlertString string `json:"-"` Alert *ApsAlert `json:"-"` - Badge int `json:"badge,omitempty"` + Badge *int `json:"badge,omitempty"` Sound string `json:"sound,omitempty"` ContentAvailable bool `json:"-"` Category string `json:"category,omitempty"` @@ -214,7 +214,7 @@ func (a *Aps) MarshalJSON() ([]byte, error) { if a.Alert != nil { s.Alert = a.Alert - } else { + } else if a.AlertString != "" { s.Alert = a.AlertString } if a.ContentAvailable { diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index ed9ccb36..fd06eac7 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -32,16 +32,21 @@ import ( 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"}), - }, -} +var ( + testMessagingConfig = &internal.MessagingConfig{ + ProjectID: "test-project", + Opts: []option.ClientOption{ + option.WithTokenSource(&internal.MockTokenSource{AccessToken: "test-token"}), + }, + } -var ttlWithNanos = time.Duration(1500) * time.Millisecond -var ttl = time.Duration(10) * time.Second -var invalidTTL = time.Duration(-10) * time.Second + 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 @@ -234,7 +239,7 @@ var validMessages = []struct { Payload: &APNSPayload{ Aps: &Aps{ AlertString: "a", - Badge: 42, + Badge: &badge, Category: "c", Sound: "s", ThreadID: "t", @@ -254,7 +259,7 @@ var validMessages = []struct { "payload": map[string]interface{}{ "aps": map[string]interface{}{ "alert": "a", - "badge": float64(42), + "badge": float64(badge), "category": "c", "sound": "s", "thread-id": "t", @@ -267,6 +272,37 @@ var validMessages = []struct { "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{ @@ -761,7 +797,7 @@ func checkFCMRequest(t *testing.T, b []byte, tr *http.Request, want map[string]i t.Fatal(err) } if !reflect.DeepEqual(parsed["message"], want) { - t.Errorf("Body = %v; want = %v", parsed["message"], want) + t.Errorf("Body = %#v; want = %#v", parsed["message"], want) } validate, ok := parsed["validate_only"] From 2ee7a9bbb3b7049fe90653d33d1c5bf77149d3ba Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 2 Feb 2018 15:50:30 -0800 Subject: [PATCH 13/16] Addressing some review comments --- messaging/messaging.go | 2 +- messaging/messaging_utils.go | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 50c74963..d51c1662 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -408,7 +408,7 @@ func (c *Client) makeSendRequest(ctx context.Context, req *fcmRequest) (string, } func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest) (*TopicManagementResponse, error) { - if req.Tokens == nil { + if len(req.Tokens) == 0 { return nil, fmt.Errorf("no tokens specified") } if len(req.Tokens) > 1000 { diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go index 75ed0406..2416f890 100644 --- a/messaging/messaging_utils.go +++ b/messaging/messaging_utils.go @@ -30,8 +30,8 @@ func validateMessage(message *Message) error { return fmt.Errorf("message must not be nil") } - target := bool2int(message.Token != "") + bool2int(message.Condition != "") + bool2int(message.Topic != "") - if target != 1 { + targets := countNonEmpty(message.Token, message.Condition, message.Topic) + if targets != 1 { return fmt.Errorf("exactly one of token, topic or condition must be specified") } @@ -122,9 +122,12 @@ func validateApsAlert(alert *ApsAlert) error { return nil } -func bool2int(b bool) int8 { - if b { - return 1 +func countNonEmpty(strings ...string) int { + count := 0 + for _, s := range strings { + if s != "" { + count++ + } } - return 0 + return count } From 7175da5400651a37d5f8bac45e57d0baf15ac733 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Tue, 6 Feb 2018 18:23:02 -0800 Subject: [PATCH 14/16] Removed some unused vars --- integration/messaging/messaging_test.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/integration/messaging/messaging_test.go b/integration/messaging/messaging_test.go index b04a8adb..d7bb0693 100644 --- a/integration/messaging/messaging_test.go +++ b/integration/messaging/messaging_test.go @@ -20,7 +20,6 @@ import ( "os" "regexp" "testing" - "time" "golang.org/x/net/context" @@ -34,15 +33,7 @@ import ( const testRegistrationToken = "fGw0qy4TGgk:APA91bGtWGjuhp4WRhHXgbabIYp1jxEKI08ofj_v1bKhWAGJQ4e3a" + "rRCWzeTfHaLz83mBnDh0aPWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE" -var ( - client *messaging.Client - testFixtures = struct { - token string - topic string - condition string - }{} - ttl = time.Duration(3) * time.Second -) +var client *messaging.Client // Enable API before testing // https://console.developers.google.com/apis/library/fcm.googleapis.com From a92123c9344c1d5cb46bb45eeada3c1a44f72191 Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Wed, 7 Feb 2018 15:53:13 -0800 Subject: [PATCH 15/16] Accepting prefixed topic names (#84) * Accepting prefixed topic named * Added a comment --- messaging/messaging.go | 16 +++++++++++++++- messaging/messaging_test.go | 11 ++++++++--- messaging/messaging_utils.go | 6 ++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index d51c1662..527bae09 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -81,10 +81,24 @@ type Message struct { Webpush *WebpushConfig `json:"webpush,omitempty"` APNS *APNSConfig `json:"apns,omitempty"` Token string `json:"token,omitempty"` - Topic string `json:"topic,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"` diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index fd06eac7..a41b04e8 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -63,6 +63,11 @@ var validMessages = []struct { 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"}, @@ -370,11 +375,11 @@ var invalidMessages = []struct { want: "exactly one of token, topic or condition must be specified", }, { - name: "InvalidTopicPrefix", + name: "InvalidPrefixedTopicName", req: &Message{ - Topic: "/topics/foo", + Topic: "/topics/", }, - want: "topic name must not contain the /topics/ prefix", + want: "malformed topic name", }, { name: "InvalidTopicName", diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go index 2416f890..ffd4df95 100644 --- a/messaging/messaging_utils.go +++ b/messaging/messaging_utils.go @@ -37,10 +37,8 @@ func validateMessage(message *Message) error { // validate topic if message.Topic != "" { - if strings.HasPrefix(message.Topic, "/topics/") { - return fmt.Errorf("topic name must not contain the /topics/ prefix") - } - if !bareTopicNamePattern.MatchString(message.Topic) { + bt := strings.TrimPrefix(message.Topic, "/topics/") + if !bareTopicNamePattern.MatchString(bt) { return fmt.Errorf("malformed topic name") } } From 0810d8bbb883e41c2ec69af59c89ee42aa2b5a27 Mon Sep 17 00:00:00 2001 From: Hiranya Jayathilaka Date: Mon, 12 Feb 2018 10:15:38 -0800 Subject: [PATCH 16/16] Using new FCM error codes (#89) --- messaging/messaging.go | 11 ++++++----- messaging/messaging_test.go | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/messaging/messaging.go b/messaging/messaging.go index 527bae09..97b77d64 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -43,11 +43,12 @@ var ( fcmErrorCodes = 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", - "PERMISSION_DENIED": "client does not have permission to perform the requested operation; code: authentication-error", - "RESOURCE_EXHAUSTED": "messaging service quota exceeded; code: message-rate-exceeded", - "UNAUTHENTICATED": "client failed to authenticate; code: authentication-error", + "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{ @@ -416,7 +417,7 @@ func (c *Client) makeSendRequest(ctx context.Context, req *fcmRequest) (string, json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level msg := fcmErrorCodes[fe.Error.Status] if msg == "" { - msg = fmt.Sprintf("client encountered an unknown error; response: %s", string(resp.Body)) + 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) } diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index a41b04e8..27808e84 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -627,7 +627,7 @@ func TestSendError(t *testing.T) { }{ { resp: "{}", - want: "http error status: 500; reason: client encountered an unknown error; response: {}", + want: "http error status: 500; reason: server responded with an unknown error; response: {}", }, { resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", @@ -635,7 +635,7 @@ func TestSendError(t *testing.T) { }, { resp: "not json", - want: "http error status: 500; reason: client encountered an unknown error; response: not json", + want: "http error status: 500; reason: server responded with an unknown error; response: not json", }, } for _, tc := range cases {