From 32af2b8141bddb7230dd4db7e6612703f2dfc25b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 22 Jun 2023 18:05:56 -0400 Subject: [PATCH 1/4] [chore] Release 4.12.0 (#561) - Release 4.12.0 --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index a06829b1..fb3b0b47 100644 --- a/firebase.go +++ b/firebase.go @@ -39,7 +39,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.11.0" +const Version = "4.12.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" From 02300a8e865290c35d0ff92ff241ee38ccd814a7 Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Tue, 11 Jul 2023 12:21:57 -0400 Subject: [PATCH 2/4] Revert "[chore] Release 4.12.0 (#561)" (#565) This reverts commit 32af2b8141bddb7230dd4db7e6612703f2dfc25b. --- firebase.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.go b/firebase.go index fb3b0b47..a06829b1 100644 --- a/firebase.go +++ b/firebase.go @@ -39,7 +39,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "4.12.0" +const Version = "4.11.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" From 5a177a8d5d397cfe0c6080b5bfc3a6f87711610f Mon Sep 17 00:00:00 2001 From: vGhost Date: Sat, 11 May 2024 15:32:24 +0400 Subject: [PATCH 3/4] + Get information about app instances + Create registration tokens for APNs tokens --- messaging/iid_mgt.go | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 messaging/iid_mgt.go diff --git a/messaging/iid_mgt.go b/messaging/iid_mgt.go new file mode 100644 index 00000000..b680c6d2 --- /dev/null +++ b/messaging/iid_mgt.go @@ -0,0 +1,105 @@ +package messaging + +import ( + "context" + "fmt" + "net/http" + + "firebase.google.com/go/v4/internal" +) + +type iidBatchImportRequest struct { + Application string `json:"application"` + Sandbox bool `json:"sandbox"` + ApnsTokens []string `json:"apns_tokens"` +} + +type RegistrationToken struct { + ApnsToken string `json:"apns_token"` + Status string `json:"status"` + RegistrationToken string `json:"registration_token,omitempty"` +} + +type iidRegistrationTokens struct { + Results []RegistrationToken `json:"results"` +} + +func (c *iidClient) GetRegistrationFromAPNs( + ctx context.Context, + application string, + tokens []string, +) ([]RegistrationToken, error) { + return c.getRegistrationFromAPNs(ctx, application, tokens, false) +} + +func (c *iidClient) GetRegistrationFromAPNsDryRun( + ctx context.Context, + application string, + tokens []string, +) ([]RegistrationToken, error) { + return c.getRegistrationFromAPNs(ctx, application, tokens, true) +} + +func (c *iidClient) getRegistrationFromAPNs( + ctx context.Context, + application string, + tokens []string, + sandbox bool, +) ([]RegistrationToken, error) { + if application == "" { + return nil, fmt.Errorf("empty application id") + } + if len(tokens) == 0 { + return nil, fmt.Errorf("empty APNs tokens") + } + + request := &internal.Request{ + Method: http.MethodPost, + URL: fmt.Sprintf("%s:batchImport", c.iidEndpoint), + Body: internal.NewJSONEntity(&iidBatchImportRequest{ + Application: application, + Sandbox: sandbox, + ApnsTokens: tokens, + }), + } + + var result iidRegistrationTokens + if _, err := c.httpClient.DoAndUnmarshal(ctx, request, &result); err != nil { + return nil, err + } + return result.Results, nil +} + +type Topics map[string]struct { + AddDate string `json:"addDate"` +} + +type TokenDetails struct { + ApplicationVersion string `json:"applicationVersion"` + Application string `json:"application"` + AuthorizedEntity string `json:"authorizedEntity"` + Rel struct { + Topics Topics `json:"topics"` + } `json:"rel"` + Platform string `json:"platform"` +} + +func (c *iidClient) GetSubscriptions(ctx context.Context, token string) (*Topics, error) { + res, err := c.GetTokenDetails(ctx, token) + if err != nil { + return nil, err + } + return &res.Rel.Topics, nil +} + +func (c *iidClient) GetTokenDetails(ctx context.Context, token string) (*TokenDetails, error) { + request := &internal.Request{ + Method: http.MethodGet, + URL: fmt.Sprintf("%s:/info/%s?details=true", c.iidEndpoint, token), + } + var result TokenDetails + if _, err := c.httpClient.DoAndUnmarshal(ctx, request, &result); err != nil { + return nil, err + } + return &result, nil +} From 610cb0710082a654dd7db17b8ffee6fd9a3ee892 Mon Sep 17 00:00:00 2001 From: vGhost Date: Sun, 12 May 2024 14:06:04 +0400 Subject: [PATCH 4/4] + docs & tests --- messaging/iid_mgt.go | 73 +++++++-- messaging/iid_mgt_test.go | 319 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 messaging/iid_mgt_test.go diff --git a/messaging/iid_mgt.go b/messaging/iid_mgt.go index b680c6d2..183bfd15 100644 --- a/messaging/iid_mgt.go +++ b/messaging/iid_mgt.go @@ -1,3 +1,17 @@ +// Copyright 2019 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 ( @@ -8,22 +22,34 @@ import ( "firebase.google.com/go/v4/internal" ) -type iidBatchImportRequest struct { - Application string `json:"application"` - Sandbox bool `json:"sandbox"` - ApnsTokens []string `json:"apns_tokens"` -} +const iidImport = "batchImport" +// RegistrationToken is the result produced by Instance ID service's batchImport method. type RegistrationToken struct { ApnsToken string `json:"apns_token"` Status string `json:"status"` RegistrationToken string `json:"registration_token,omitempty"` } +type iidBatchImportRequest struct { + Application string `json:"application"` + Sandbox bool `json:"sandbox"` + ApnsTokens []string `json:"apns_tokens"` +} + type iidRegistrationTokens struct { Results []RegistrationToken `json:"results"` } +// GetRegistrationFromAPNs Create registration tokens for APNs tokens. +// +// Using the Instance ID service's batchImport method, you can bulk import existing iOS APNs tokens to +// Firebase Cloud Messaging, mapping them to valid registration tokens. +// +// The response contains an array of Instance ID registration tokens ready to be used for +// sending FCM messages to the corresponding APNs device token. +// +// https://developers.google.com/instance-id/reference/server#create_registration_tokens_for_apns_tokens func (c *iidClient) GetRegistrationFromAPNs( ctx context.Context, application string, @@ -32,6 +58,7 @@ func (c *iidClient) GetRegistrationFromAPNs( return c.getRegistrationFromAPNs(ctx, application, tokens, false) } +// GetRegistrationFromAPNsDryRun Create registration tokens for APNs tokens in the dry run (sandbox) mode. func (c *iidClient) GetRegistrationFromAPNsDryRun( ctx context.Context, application string, @@ -47,15 +74,23 @@ func (c *iidClient) getRegistrationFromAPNs( sandbox bool, ) ([]RegistrationToken, error) { if application == "" { - return nil, fmt.Errorf("empty application id") + return nil, fmt.Errorf("application id not specified") } if len(tokens) == 0 { - return nil, fmt.Errorf("empty APNs tokens") + return nil, fmt.Errorf("no APNs tokens specified") + } + if len(tokens) > 100 { + return nil, fmt.Errorf("too many APNs tokens specified") + } + for i := range tokens { + if len(tokens[i]) == 0 { + return nil, fmt.Errorf("tokens list must not contain empty strings") + } } request := &internal.Request{ Method: http.MethodPost, - URL: fmt.Sprintf("%s:batchImport", c.iidEndpoint), + URL: fmt.Sprintf("%s:%s", c.iidEndpoint, iidImport), Body: internal.NewJSONEntity(&iidBatchImportRequest{ Application: application, Sandbox: sandbox, @@ -70,10 +105,19 @@ func (c *iidClient) getRegistrationFromAPNs( return result.Results, nil } +// Topics information about relations associated with the token. type Topics map[string]struct { AddDate string `json:"addDate"` } +// TokenDetails information about app instances. +// Object containing: +// +// application - package name associated with the token. +// authorizedEntity - projectId authorized to send to the token. +// applicationVersion - version of the application. +// platform - returns ANDROID, IOS, or CHROME to indicate the device platform to which the token belongs. +// rel - relations associated with the token. For example, a list of topic subscriptions. type TokenDetails struct { ApplicationVersion string `json:"applicationVersion"` Application string `json:"application"` @@ -84,15 +128,24 @@ type TokenDetails struct { Platform string `json:"platform"` } -func (c *iidClient) GetSubscriptions(ctx context.Context, token string) (*Topics, error) { +// GetSubscriptions Get information about relations associated with the token. +func (c *iidClient) GetSubscriptions(ctx context.Context, token string) (Topics, error) { res, err := c.GetTokenDetails(ctx, token) if err != nil { return nil, err } - return &res.Rel.Topics, nil + return res.Rel.Topics, nil } +// GetTokenDetails Get information about app instances +// On success the call returns object TokenDetails +// +// https://developers.google.com/instance-id/reference/server#get_information_about_app_instances func (c *iidClient) GetTokenDetails(ctx context.Context, token string) (*TokenDetails, error) { + if token == "" { + return nil, fmt.Errorf("token not specified") + } + request := &internal.Request{ Method: http.MethodGet, URL: fmt.Sprintf("%s:/info/%s?details=true", c.iidEndpoint, token), diff --git a/messaging/iid_mgt_test.go b/messaging/iid_mgt_test.go new file mode 100644 index 00000000..417e87db --- /dev/null +++ b/messaging/iid_mgt_test.go @@ -0,0 +1,319 @@ +// Copyright 2019 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package messaging + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "firebase.google.com/go/v4/errorutils" +) + +var rfBody = []byte("{\"results\": [{\"apns_token\": \"id1\", \"status\": \"OK\", \"registration_token\": " + + "\"test-id1\"},{\"apns_token\": \"id1\", \"status\": \"Internal Server Error\"}]}") + +func TestGetRegistrationFromAPNs(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, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(rfBody) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + "/v1" + + resp, err := client.GetRegistrationFromAPNs(ctx, "test-app", []string{"id1", "id2"}) + if err != nil { + t.Fatal(err) + } + checkRegistrationFromAPNsRequest(t, b, tr, iidImport, false) + checkRegistrationFromAPNsResponse(t, resp) +} + +func TestGetRegistrationFromAPNsDryRun(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, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(rfBody) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + "/v1" + + resp, err := client.GetRegistrationFromAPNsDryRun(ctx, "test-app", []string{"id1", "id2"}) + if err != nil { + t.Fatal(err) + } + checkRegistrationFromAPNsRequest(t, b, tr, iidImport, true) + checkRegistrationFromAPNsResponse(t, resp) +} + +func TestInvalidGetRegistrationFromAPNs(t *testing.T) { + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + var invalidArgs = []struct { + name string + tokens []string + app string + want string + }{ + { + name: "NoTokens", + app: "app", + want: "no APNs tokens specified", + }, + { + name: "NoApplicationID", + tokens: []string{"token1"}, + want: "application id not specified", + }, + { + name: "TooManyTokens", + tokens: strings.Split("a"+strings.Repeat(",a", 100), ","), + app: "app", + want: "too many APNs tokens specified", + }, + { + name: "EmptyToken", + tokens: []string{"foo", ""}, + app: "app", + want: "tokens list must not contain empty strings", + }, + } + + for _, tc := range invalidArgs { + t.Run(tc.name, func(t *testing.T) { + resp, err2 := client.getRegistrationFromAPNs(ctx, tc.app, tc.tokens, true) + if err2 == nil || err2.Error() != tc.want { + t.Errorf( + "getRegistrationFromAPNs(%s) = (%#v, %v); want = (nil, %q)", tc.name, resp, err2, tc.want) + } + }) + } +} + +func checkRegistrationFromAPNsRequest(t *testing.T, b []byte, tr *http.Request, op string, sandbox bool) { + var parsed map[string]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + t.Fatal(err) + } + want := map[string]interface{}{ + "application": "test-app", + "sandbox": sandbox, + "apns_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 := "/v1:" + 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") + } + if h := tr.Header.Get("Content-Type"); h != "application/json" { + t.Errorf("Content-Type = %q; want = %q", h, "application/json") + } + if h := tr.Header.Get("Access_token_auth"); h != "true" { + t.Errorf("Access_token_auth = %q; want = %q", h, "true") + } +} + +func checkRegistrationFromAPNsResponse(t *testing.T, resp []RegistrationToken) { + if len(resp) != 2 { + t.Errorf("RegistrationToken length = %d; want = %d", len(resp), 2) + } + + if resp[0].Status != "OK" { + t.Errorf("Status = %q; want = %q", resp[0].Status, "OK") + } + if resp[1].Status != "Internal Server Error" { + t.Errorf("Status = %q; want = %q", resp[1].Status, "Internal Server Error") + } + + if resp[0].ApnsToken != "id1" { + t.Errorf("ApnsToken = %q; want = %q", resp[0].ApnsToken, "id1") + } + if resp[1].ApnsToken != "id1" { + t.Errorf("ApnsToken = %q; want = %q", resp[1].ApnsToken, "id2") + } + + if resp[0].RegistrationToken != "test-id1" { + t.Errorf("ApnsToken = %q; want = %q", resp[0].RegistrationToken, "test-id1") + } + if resp[1].RegistrationToken != "" { + t.Errorf("ApnsToken = %q; want = %q", resp[1].RegistrationToken, "") + } +} + +func TestGetTokenDetails(t *testing.T) { + var tr *http.Request + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tr = r + if r.Body != http.NoBody { + t.Errorf("Request body must be empty") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{\"application\":\"com.iid.example\",\"authorizedEntity\":\"123456782354\"," + + "\"platform\":\"Android\",\"rel\":{\"topics\":{\"topicName1\":{\"addDate\":\"2015-07-30\"}," + + "\"topicName2\":{\"addDate\":\"2015-07-30\"}}}}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + "/v1" + + resp, err := client.GetTokenDetails(ctx, "") + if err == nil || err.Error() != "token not specified" { + t.Errorf("GetTokenDetails(EmptyToken) = (%#v, %v); want = (nil, %q)", resp, err, "token not specified") + } + + resp, err = client.GetTokenDetails(ctx, "id1") + if err != nil { + t.Fatal(err) + } + + if tr.Method != http.MethodGet { + t.Errorf("Method = %q; want = %q", tr.Method, http.MethodGet) + } + if h := tr.Header.Get("Authorization"); h != "Bearer test-token" { + t.Errorf("Authorization = %q; want = %q", h, "Bearer test-token") + } + if h := tr.Header.Get("Access_token_auth"); h != "true" { + t.Errorf("Access_token_auth = %q; want = %q", h, "true") + } + if tr.URL.Path != "/v1:/info/id1" { + t.Errorf("Path = %q; want = %q", tr.URL.Path, "/v1:/info/id1") + } + + if resp.Platform != "Android" { + t.Errorf("Platform = %q; want = %q", resp.Platform, "Android") + } + if resp.Application != "com.iid.example" { + t.Errorf("Application = %q; want = %q", resp.Platform, "com.iid.example") + } + if resp.AuthorizedEntity != "123456782354" { + t.Errorf("AuthorizedEntity = %q; want = %q", resp.Platform, "123456782354") + } + if len(resp.Rel.Topics) != 2 { + t.Errorf("Topics count = %d; want = %d", len(resp.Rel.Topics), 2) + } + if v, exists := resp.Rel.Topics["topicName1"]; exists { + if v.AddDate != "2015-07-30" { + t.Errorf("Topics date = %q; want = %q", v.AddDate, "2015-07-30") + } + } else { + t.Errorf("Topic \"topicname1\" not exists") + } +} + +func TestGetSubscriptions(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != http.NoBody { + t.Errorf("Request body must be empty") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{\"application\":\"com.iid.example\",\"authorizedEntity\":\"123456782354\"," + + "\"platform\":\"Android\",\"rel\":{\"topics\":{\"topicName1\":{\"addDate\":\"2015-07-30\"}," + + "\"topicName2\":{\"addDate\":\"2015-07-30\"}}}}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + "/v1" + + resp, err := client.GetSubscriptions(ctx, "id1") + if err != nil { + t.Fatal(err) + } + + if len(resp) != 2 { + t.Errorf("Topics count = %d; want = %d", len(resp), 2) + } + if v, exists := resp["topicName1"]; exists { + if v.AddDate != "2015-07-30" { + t.Errorf("Topics date = %q; want = %q", v.AddDate, "2015-07-30") + } + } else { + t.Errorf("Topic \"topicname1\" not exists") + } +} + +func TestInvalidResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("{\"error\":\"InvalidToken\"}")) + })) + defer ts.Close() + + ctx := context.Background() + client, err := NewClient(ctx, testMessagingConfig) + if err != nil { + t.Fatal(err) + } + client.iidEndpoint = ts.URL + "/v1" + + const text = "error while calling the iid service: InvalidToken" + + resp, err := client.GetSubscriptions(ctx, "id1") + if !errorutils.IsInvalidArgument(err) { + t.Errorf( + "GetSubscriptions(InvalidToken) = (%#v, %v); want = (nil, %q)", resp, err, text) + } + resp2, err := client.GetRegistrationFromAPNsDryRun(ctx, "test-app", []string{"id1", "id2"}) + if !errorutils.IsInvalidArgument(err) { + t.Errorf( + "GetSubscriptions(InvalidToken) = (%#v, %v); want = (nil, %q)", resp2, err, text) + } +}