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") + } +}