diff --git a/CHANGELOG.md b/CHANGELOG.md index 893bb9ae..023a03ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Unreleased +- + +# v2.7.0 + +- [added] Added several new functions for testing errors + (e.g. `auth.IsUserNotFound()`). +- [added] Added support for setting the `mutable-content` property on + FCM messages sent via APNS. +- [changed] Updated the error messages returned by the `messaging` + package. These errors now contain the full details sent by the + back-end server. + # v2.6.1 - [added] Added support for Go 1.6. diff --git a/auth/auth.go b/auth/auth.go index 2576165e..8e7ce3f3 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -265,7 +265,7 @@ func (c *Client) VerifyIDTokenAndCheckRevoked(ctx context.Context, idToken strin } if p.IssuedAt*1000 < user.TokensValidAfterMillis { - return nil, fmt.Errorf("ID token has been revoked") + return nil, internal.Error(idTokenRevoked, "ID token has been revoked") } return p, nil } diff --git a/auth/auth_std.go b/auth/auth_std.go index 2055af38..309af03e 100644 --- a/auth/auth_std.go +++ b/auth/auth_std.go @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package auth +package auth // import "firebase.google.com/go/auth" import "golang.org/x/net/context" diff --git a/auth/auth_test.go b/auth/auth_test.go index 6aea0d3a..1727f13f 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -233,7 +233,7 @@ func TestVerifyIDTokenAndCheckRevokedInvalidated(t *testing.T) { p, err := s.Client.VerifyIDTokenAndCheckRevoked(ctx, tok) we := "ID token has been revoked" - if p != nil || err == nil || err.Error() != we { + if p != nil || err == nil || err.Error() != we || !IsIDTokenRevoked(err) { t.Errorf("VerifyIDTokenAndCheckRevoked(ctx, token) =(%v, %v); want = (%v, %v)", p, err, nil, we) } diff --git a/auth/user_mgt.go b/auth/user_mgt.go index e6d7386b..dda132ef 100644 --- a/auth/user_mgt.go +++ b/auth/user_mgt.go @@ -23,8 +23,10 @@ import ( "strings" "time" + "firebase.google.com/go/internal" "golang.org/x/net/context" + "google.golang.org/api/googleapi" "google.golang.org/api/identitytoolkit/v3" "google.golang.org/api/iterator" ) @@ -215,8 +217,10 @@ func (c *Client) DeleteUser(ctx context.Context, uid string) error { call := c.is.Relyingparty.DeleteAccount(request) c.setHeader(call) - _, err := call.Context(ctx).Do() - return err + if _, err := call.Context(ctx).Do(); err != nil { + return handleServerError(err) + } + return nil } // GetUser gets the user data corresponding to the specified user ID. @@ -279,7 +283,7 @@ func (it *UserIterator) fetch(pageSize int, pageToken string) (string, error) { it.client.setHeader(call) resp, err := call.Context(it.ctx).Do() if err != nil { - return "", err + return "", handleServerError(err) } for _, u := range resp.Users { @@ -345,10 +349,7 @@ func processClaims(p map[string]interface{}) error { return nil } - claims, ok := cc.(map[string]interface{}) - if !ok { - return fmt.Errorf("unexpected type for custom claims") - } + claims := cc.(map[string]interface{}) for _, key := range reservedClaims { if _, ok := claims[key]; ok { return fmt.Errorf("claim %q is reserved and must not be set", key) @@ -372,6 +373,83 @@ func processClaims(p map[string]interface{}) error { return nil } +// Error handlers. + +const ( + emailAlredyExists = "email-already-exists" + idTokenRevoked = "id-token-revoked" + insufficientPermission = "insufficient-permission" + phoneNumberAlreadyExists = "phone-number-already-exists" + projectNotFound = "project-not-found" + uidAlreadyExists = "uid-already-exists" + unknown = "unknown-error" + userNotFound = "user-not-found" +) + +// IsEmailAlreadyExists checks if the given error was due to a duplicate email. +func IsEmailAlreadyExists(err error) bool { + return internal.HasErrorCode(err, emailAlredyExists) +} + +// IsIDTokenRevoked checks if the given error was due to a revoked ID token. +func IsIDTokenRevoked(err error) bool { + return internal.HasErrorCode(err, idTokenRevoked) +} + +// IsInsufficientPermission checks if the given error was due to insufficient permissions. +func IsInsufficientPermission(err error) bool { + return internal.HasErrorCode(err, insufficientPermission) +} + +// IsPhoneNumberAlreadyExists checks if the given error was due to a duplicate phone number. +func IsPhoneNumberAlreadyExists(err error) bool { + return internal.HasErrorCode(err, phoneNumberAlreadyExists) +} + +// IsProjectNotFound checks if the given error was due to a non-existing project. +func IsProjectNotFound(err error) bool { + return internal.HasErrorCode(err, projectNotFound) +} + +// IsUIDAlreadyExists checks if the given error was due to a duplicate uid. +func IsUIDAlreadyExists(err error) bool { + return internal.HasErrorCode(err, uidAlreadyExists) +} + +// IsUnknown checks if the given error was due to a unknown server error. +func IsUnknown(err error) bool { + return internal.HasErrorCode(err, unknown) +} + +// IsUserNotFound checks if the given error was due to non-existing user. +func IsUserNotFound(err error) bool { + return internal.HasErrorCode(err, userNotFound) +} + +var serverError = map[string]string{ + "CONFIGURATION_NOT_FOUND": projectNotFound, + "DUPLICATE_EMAIL": emailAlredyExists, + "DUPLICATE_LOCAL_ID": uidAlreadyExists, + "EMAIL_EXISTS": emailAlredyExists, + "INSUFFICIENT_PERMISSION": insufficientPermission, + "PHONE_NUMBER_EXISTS": phoneNumberAlreadyExists, + "PROJECT_NOT_FOUND": projectNotFound, +} + +func handleServerError(err error) error { + gerr, ok := err.(*googleapi.Error) + if !ok { + // Not a back-end error + return err + } + serverCode := gerr.Message + clientCode, ok := serverError[serverCode] + if !ok { + clientCode = unknown + } + return internal.Error(clientCode, err.Error()) +} + // Validators. func validateDisplayName(val interface{}) error { @@ -532,7 +610,7 @@ func (c *Client) createUser(ctx context.Context, user *UserToCreate) (string, er c.setHeader(call) resp, err := call.Context(ctx).Do() if err != nil { - return "", err + return "", handleServerError(err) } return resp.LocalId, nil @@ -555,9 +633,10 @@ func (c *Client) updateUser(ctx context.Context, uid string, user *UserToUpdate) call := c.is.Relyingparty.SetAccountInfo(request) c.setHeader(call) - _, err := call.Context(ctx).Do() - - return err + if _, err := call.Context(ctx).Do(); err != nil { + return handleServerError(err) + } + return nil } func (c *Client) getUser(ctx context.Context, request *identitytoolkit.IdentitytoolkitRelyingpartyGetAccountInfoRequest) (*UserRecord, error) { @@ -565,10 +644,18 @@ func (c *Client) getUser(ctx context.Context, request *identitytoolkit.Identityt c.setHeader(call) resp, err := call.Context(ctx).Do() if err != nil { - return nil, err + return nil, handleServerError(err) } if len(resp.Users) == 0 { - return nil, fmt.Errorf("cannot find user given params: id:%v, phone:%v, email: %v", request.LocalId, request.PhoneNumber, request.Email) + var msg string + if len(request.LocalId) == 1 { + msg = fmt.Sprintf("cannot find user from uid: %q", request.LocalId[0]) + } else if len(request.Email) == 1 { + msg = fmt.Sprintf("cannot find user from email: %q", request.Email[0]) + } else { + msg = fmt.Sprintf("cannot find user from phone number: %q", request.PhoneNumber[0]) + } + return nil, internal.Error(userNotFound, msg) } eu, err := makeExportedUser(resp.Users[0]) @@ -581,8 +668,7 @@ func (c *Client) getUser(ctx context.Context, request *identitytoolkit.Identityt func makeExportedUser(r *identitytoolkit.UserInfo) (*ExportedUserRecord, error) { var cc map[string]interface{} if r.CustomAttributes != "" { - err := json.Unmarshal([]byte(r.CustomAttributes), &cc) - if err != nil { + if err := json.Unmarshal([]byte(r.CustomAttributes), &cc); err != nil { return nil, err } if len(cc) == 0 { diff --git a/auth/user_mgt_test.go b/auth/user_mgt_test.go index 30072d4f..ce9faccd 100644 --- a/auth/user_mgt_test.go +++ b/auth/user_mgt_test.go @@ -149,23 +149,21 @@ func TestGetNonExistingUser(t *testing.T) { s := echoServer([]byte(resp), t) defer s.Close() - want := "cannot find user given params: id:[%s], phone:[%s], email: [%s]" - - we := fmt.Sprintf(want, "id-nonexisting", "", "") + we := `cannot find user from uid: "id-nonexisting"` user, err := s.Client.GetUser(context.Background(), "id-nonexisting") - if user != nil || err == nil || err.Error() != we { + if user != nil || err == nil || err.Error() != we || !IsUserNotFound(err) { t.Errorf("GetUser(non-existing) = (%v, %q); want = (nil, %q)", user, err, we) } - we = fmt.Sprintf(want, "", "", "foo@bar.nonexisting") + we = `cannot find user from email: "foo@bar.nonexisting"` user, err = s.Client.GetUserByEmail(context.Background(), "foo@bar.nonexisting") - if user != nil || err == nil || err.Error() != we { + if user != nil || err == nil || err.Error() != we || !IsUserNotFound(err) { t.Errorf("GetUserByEmail(non-existing) = (%v, %q); want = (nil, %q)", user, err, we) } - we = fmt.Sprintf(want, "", "+12345678901", "") + we = `cannot find user from phone number: "+12345678901"` user, err = s.Client.GetUserByPhoneNumber(context.Background(), "+12345678901") - if user != nil || err == nil || err.Error() != we { + if user != nil || err == nil || err.Error() != we || !IsUserNotFound(err) { t.Errorf("GetUserPhoneNumber(non-existing) = (%v, %q); want = (nil, %q)", user, err, we) } } @@ -642,7 +640,6 @@ func TestInvalidDeleteUser(t *testing.T) { } func TestMakeExportedUser(t *testing.T) { - rur := &identitytoolkit.UserInfo{ LocalId: "testuser", Email: "testuser@example.com", @@ -704,11 +701,39 @@ func TestHTTPError(t *testing.T) { } want := `googleapi: got HTTP response code 500 with body: {"error":"test"}` - if err.Error() != want { + if err.Error() != want || !IsUnknown(err) { t.Errorf("GetUser() = %v; want = %q", err, want) } } +func TestHTTPErrorWithCode(t *testing.T) { + errorCodes := map[string]func(error) bool{ + "CONFIGURATION_NOT_FOUND": IsProjectNotFound, + "DUPLICATE_EMAIL": IsEmailAlreadyExists, + "DUPLICATE_LOCAL_ID": IsUIDAlreadyExists, + "EMAIL_EXISTS": IsEmailAlreadyExists, + "INSUFFICIENT_PERMISSION": IsInsufficientPermission, + "PHONE_NUMBER_EXISTS": IsPhoneNumberAlreadyExists, + "PROJECT_NOT_FOUND": IsProjectNotFound, + } + s := echoServer(nil, t) + defer s.Close() + s.Status = http.StatusInternalServerError + + for code, check := range errorCodes { + s.Resp = []byte(fmt.Sprintf(`{"error":{"message":"%s"}}`, code)) + u, err := s.Client.GetUser(context.Background(), "some uid") + if u != nil || err == nil { + t.Fatalf("GetUser() = (%v, %v); want = (nil, error)", u, err) + } + + want := fmt.Sprintf("googleapi: Error 500: %s", code) + if err.Error() != want || !check(err) { + t.Errorf("GetUser() = %v; want = %q", err, want) + } + } +} + type mockAuthServer struct { Resp []byte Header map[string]string diff --git a/db/query.go b/db/query.go index ca377c12..ce9a670b 100644 --- a/db/query.go +++ b/db/query.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package db +package db // import "firebase.google.com/go/db" import ( "encoding/json" diff --git a/firebase.go b/firebase.go index c341dfce..36d16908 100644 --- a/firebase.go +++ b/firebase.go @@ -15,7 +15,7 @@ // Package firebase is the entry point to the Firebase Admin SDK. It provides functionality for initializing App // instances, which serve as the central entities that provide access to various other Firebase services exposed // from the SDK. -package firebase +package firebase // import "firebase.google.com/go" import ( "encoding/json" @@ -42,7 +42,7 @@ import ( var defaultAuthOverrides = make(map[string]interface{}) // Version of the Firebase Go Admin SDK. -const Version = "2.6.1" +const Version = "2.7.0" // firebaseEnvName is the name of the environment variable with the Config. const firebaseEnvName = "FIREBASE_CONFIG" diff --git a/iid/iid.go b/iid/iid.go index 566833e6..66dab244 100644 --- a/iid/iid.go +++ b/iid/iid.go @@ -13,7 +13,7 @@ // limitations under the License. // Package iid contains functions for deleting instance IDs from Firebase projects. -package iid +package iid // import "firebase.google.com/go/iid" import ( "errors" diff --git a/internal/internal.go b/internal/internal.go index bc4f41d1..20afef60 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -13,9 +13,11 @@ // limitations under the License. // Package internal contains functionality that is only accessible from within the Admin SDK. -package internal +package internal // import "firebase.google.com/go/internal" import ( + "fmt" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/option" @@ -59,11 +61,6 @@ type StorageConfig struct { Bucket string } -// MockTokenSource is a TokenSource implementation that can be used for testing. -type MockTokenSource struct { - AccessToken string -} - // MessagingConfig represents the configuration of Firebase Cloud Messaging service. type MessagingConfig struct { Opts []option.ClientOption @@ -71,6 +68,40 @@ type MessagingConfig struct { Version string } +// FirebaseError is an error type containing an error code string. +type FirebaseError struct { + Code string + String string +} + +func (fe *FirebaseError) Error() string { + return fe.String +} + +// HasErrorCode checks if the given error contain a specific error code. +func HasErrorCode(err error, code string) bool { + fe, ok := err.(*FirebaseError) + return ok && fe.Code == code +} + +// Error creates a new FirebaseError from the specified error code and message. +func Error(code string, msg string) *FirebaseError { + return &FirebaseError{ + Code: code, + String: msg, + } +} + +// Errorf creates a new FirebaseError from the specified error code and message. +func Errorf(code string, msg string, args ...interface{}) *FirebaseError { + return Error(code, fmt.Sprintf(msg, args...)) +} + +// MockTokenSource is a TokenSource implementation that can be used for testing. +type MockTokenSource struct { + AccessToken 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 index fb6fa5dd..b44cb0d9 100644 --- a/messaging/messaging.go +++ b/messaging/messaging.go @@ -14,7 +14,7 @@ // Package messaging contains functions for sending messages and managing // device subscriptions with Firebase Cloud Messaging (FCM). -package messaging +package messaging // import "firebase.google.com/go/messaging" import ( "encoding/json" @@ -25,9 +25,9 @@ import ( "strings" "time" + "firebase.google.com/go/internal" "golang.org/x/net/context" - "firebase.google.com/go/internal" "google.golang.org/api/transport" ) @@ -36,33 +36,88 @@ const ( iidEndpoint = "https://iid.googleapis.com" iidSubscribe = "iid/v1:batchAdd" iidUnsubscribe = "iid/v1:batchRemove" + + internalError = "internal-error" + invalidAPNSCredentials = "invalid-apns-credentials" + invalidArgument = "invalid-argument" + messageRateExceeded = "message-rate-exceeded" + mismatchedCredential = "mismatched-credential" + registrationTokenNotRegistered = "registration-token-not-registered" + serverUnavailable = "server-unavailable" + tooManyTopics = "too-many-topics" + unknownError = "unknown-error" ) var ( topicNamePattern = regexp.MustCompile("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$") - fcmErrorCodes = map[string]string{ + fcmErrorCodes = map[string]struct{ Code, Msg string }{ // FCM v1 canonical error codes - "NOT_FOUND": "app instance has been unregistered; code: registration-token-not-registered", - "PERMISSION_DENIED": "sender id does not match regisration token; code: mismatched-credential", - "RESOURCE_EXHAUSTED": "messaging service quota exceeded; code: message-rate-exceeded", - "UNAUTHENTICATED": "apns certificate or auth key was invalid; code: invalid-apns-credentials", + "NOT_FOUND": { + registrationTokenNotRegistered, + "app instance has been unregistered; code: " + registrationTokenNotRegistered, + }, + "PERMISSION_DENIED": { + mismatchedCredential, + "sender id does not match regisration token; code: " + mismatchedCredential, + }, + "RESOURCE_EXHAUSTED": { + messageRateExceeded, + "messaging service quota exceeded; code: " + messageRateExceeded, + }, + "UNAUTHENTICATED": { + invalidAPNSCredentials, + "apns certificate or auth key was invalid; code: " + invalidAPNSCredentials, + }, // FCM v1 new error codes - "APNS_AUTH_ERROR": "apns certificate or auth key was invalid; code: invalid-apns-credentials", - "INTERNAL": "back servers encountered an unknown internl error; code: internal-error", - "INVALID_ARGUMENT": "request contains an invalid argument; code: invalid-argument", - "SENDER_ID_MISMATCH": "sender id does not match regisration token; code: mismatched-credential", - "QUOTA_EXCEEDED": "messaging service quota exceeded; code: message-rate-exceeded", - "UNAVAILABLE": "backend servers are temporarily unavailable; code: server-unavailable", - "UNREGISTERED": "app instance has been unregistered; code: registration-token-not-registered", + "APNS_AUTH_ERROR": { + invalidAPNSCredentials, + "apns certificate or auth key was invalid; code: " + invalidAPNSCredentials, + }, + "INTERNAL": { + internalError, + "backend servers encountered an unknown internl error; code: " + internalError, + }, + "INVALID_ARGUMENT": { + invalidArgument, + "request contains an invalid argument; code: " + invalidArgument, + }, + "SENDER_ID_MISMATCH": { + mismatchedCredential, + "sender id does not match regisration token; code: " + mismatchedCredential, + }, + "QUOTA_EXCEEDED": { + messageRateExceeded, + "messaging service quota exceeded; code: " + messageRateExceeded, + }, + "UNAVAILABLE": { + serverUnavailable, + "backend servers are temporarily unavailable; code: " + serverUnavailable, + }, + "UNREGISTERED": { + registrationTokenNotRegistered, + "app instance has been unregistered; code: " + registrationTokenNotRegistered, + }, } - 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", + iidErrorCodes = map[string]struct{ Code, Msg string }{ + "INVALID_ARGUMENT": { + invalidArgument, + "request contains an invalid argument; code: " + invalidArgument, + }, + "NOT_FOUND": { + registrationTokenNotRegistered, + "request contains an invalid argument; code: " + registrationTokenNotRegistered, + }, + "INTERNAL": { + internalError, + "server encountered an internal error; code: " + internalError, + }, + "TOO_MANY_TOPICS": { + tooManyTopics, + "client exceeded the number of allowed topics; code: " + tooManyTopics, + }, } ) @@ -214,36 +269,53 @@ func (p *APNSPayload) MarshalJSON() ([]byte, error) { // Alert may be specified as a string (via the AlertString field), or as a struct (via the Alert // field). type Aps struct { - AlertString string `json:"-"` - Alert *ApsAlert `json:"-"` - Badge *int `json:"badge,omitempty"` - Sound string `json:"sound,omitempty"` - ContentAvailable bool `json:"-"` - Category string `json:"category,omitempty"` - ThreadID string `json:"thread-id,omitempty"` + AlertString string + Alert *ApsAlert + Badge *int + Sound string + ContentAvailable bool + MutableContent bool + Category string + ThreadID string + CustomData map[string]interface{} } -// MarshalJSON marshals an Aps into JSON (for internal use only). -func (a *Aps) MarshalJSON() ([]byte, error) { - type apsAlias Aps - s := &struct { - Alert interface{} `json:"alert,omitempty"` - ContentAvailable *int `json:"content-available,omitempty"` - *apsAlias - }{ - apsAlias: (*apsAlias)(a), - } - +// standardFields creates a map containing all the fields except the custom data. +func (a *Aps) standardFields() map[string]interface{} { + m := make(map[string]interface{}) if a.Alert != nil { - s.Alert = a.Alert + m["alert"] = a.Alert } else if a.AlertString != "" { - s.Alert = a.AlertString + m["alert"] = a.AlertString } if a.ContentAvailable { - one := 1 - s.ContentAvailable = &one + m["content-available"] = 1 } - return json.Marshal(s) + if a.MutableContent { + m["mutable-content"] = 1 + } + if a.Badge != nil { + m["badge"] = *a.Badge + } + if a.Sound != "" { + m["sound"] = a.Sound + } + if a.Category != "" { + m["category"] = a.Category + } + if a.ThreadID != "" { + m["thread-id"] = a.ThreadID + } + return m +} + +// MarshalJSON marshals an Aps into JSON (for internal use only). +func (a *Aps) MarshalJSON() ([]byte, error) { + m := a.standardFields() + for k, v := range a.CustomData { + m[k] = v + } + return json.Marshal(m) } // ApsAlert is the alert payload that can be included in an Aps. @@ -286,9 +358,12 @@ func newTopicManagementResponse(resp *iidResponse) *TopicManagementResponse { } else { tmr.FailureCount++ code := res["error"].(string) - reason := iidErrorCodes[code] - if reason == "" { - reason = "unknown-error" + info, ok := iidErrorCodes[code] + var reason string + if ok { + reason = info.Msg + } else { + reason = unknownError } tmr.Errors = append(tmr.Errors, &ErrorInfo{ Index: idx, @@ -370,6 +445,56 @@ func (c *Client) UnsubscribeFromTopic(ctx context.Context, tokens []string, topi return c.makeTopicManagementRequest(ctx, req) } +// IsInternal checks if the given error was due to an internal server error. +func IsInternal(err error) bool { + return internal.HasErrorCode(err, internalError) +} + +// IsInvalidAPNSCredentials checks if the given error was due to invalid APNS certificate or auth +// key. +func IsInvalidAPNSCredentials(err error) bool { + return internal.HasErrorCode(err, invalidAPNSCredentials) +} + +// IsInvalidArgument checks if the given error was due to an invalid argument in the request. +func IsInvalidArgument(err error) bool { + return internal.HasErrorCode(err, invalidArgument) +} + +// IsMessageRateExceeded checks if the given error was due to the client exceeding a quota. +func IsMessageRateExceeded(err error) bool { + return internal.HasErrorCode(err, messageRateExceeded) +} + +// IsMismatchedCredential checks if the given error was due to an invalid credential or permission +// error. +func IsMismatchedCredential(err error) bool { + return internal.HasErrorCode(err, mismatchedCredential) +} + +// IsRegistrationTokenNotRegistered checks if the given error was due to a registration token that +// became invalid. +func IsRegistrationTokenNotRegistered(err error) bool { + return internal.HasErrorCode(err, registrationTokenNotRegistered) +} + +// IsServerUnavailable checks if the given error was due to the backend server being temporarily +// unavailable. +func IsServerUnavailable(err error) bool { + return internal.HasErrorCode(err, serverUnavailable) +} + +// IsTooManyTopics checks if the given error was due to the client exceeding the allowed number +// of topics. +func IsTooManyTopics(err error) bool { + return internal.HasErrorCode(err, tooManyTopics) +} + +// IsUnknown checks if the given error was due to unknown error returned by the backend server. +func IsUnknown(err error) bool { + return internal.HasErrorCode(err, unknownError) +} + type fcmRequest struct { ValidateOnly bool `json:"validate_only,omitempty"` Message *Message `json:"message,omitempty"` @@ -381,7 +506,12 @@ type fcmResponse struct { type fcmError struct { Error struct { - Status string `json:"status"` + Status string `json:"status"` + Message string `json:"message"` + Details []struct { + Type string `json:"@type"` + ErrorCode string `json:"errorCode"` + } } `json:"error"` } @@ -422,11 +552,29 @@ func (c *Client) makeSendRequest(ctx context.Context, req *fcmRequest) (string, var fe fcmError json.Unmarshal(resp.Body, &fe) // ignore any json parse errors at this level - msg := fcmErrorCodes[fe.Error.Status] - if msg == "" { + var serverCode string + for _, d := range fe.Error.Details { + if d.Type == "type.googleapis.com/google.firebase.fcm.v1.FcmErrorCode" { + serverCode = d.ErrorCode + break + } + } + if serverCode == "" { + serverCode = fe.Error.Status + } + + var clientCode, msg string + info, ok := fcmErrorCodes[serverCode] + if ok { + clientCode, msg = info.Code, info.Msg + } else { + clientCode = unknownError 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) + if fe.Error.Message != "" { + msg += "; details: " + fe.Error.Message + } + return "", internal.Errorf(clientCode, "http error status: %d; reason: %s", resp.Status, msg) } func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest) (*TopicManagementResponse, error) { @@ -474,9 +622,13 @@ func (c *Client) makeTopicManagementRequest(ctx context.Context, req *iidRequest var ie iidError json.Unmarshal(resp.Body, &ie) // ignore any json parse errors at this level - msg := iidErrorCodes[ie.Error] - if msg == "" { + var clientCode, msg string + info, ok := iidErrorCodes[ie.Error] + if ok { + clientCode, msg = info.Code, info.Msg + } else { + clientCode = unknownError 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) + return nil, internal.Errorf(clientCode, "http error status: %d; reason: %s", resp.Status, msg) } diff --git a/messaging/messaging_test.go b/messaging/messaging_test.go index 1d6d3ad5..62231108 100644 --- a/messaging/messaging_test.go +++ b/messaging/messaging_test.go @@ -249,6 +249,7 @@ var validMessages = []struct { Sound: "s", ThreadID: "t", ContentAvailable: true, + MutableContent: true, }, CustomData: map[string]interface{}{ "k1": "v1", @@ -269,6 +270,7 @@ var validMessages = []struct { "sound": "s", "thread-id": "t", "content-available": float64(1), + "mutable-content": float64(1), }, "k1": "v1", "k2": true, @@ -288,6 +290,8 @@ var validMessages = []struct { Sound: "s", ThreadID: "t", ContentAvailable: true, + MutableContent: true, + CustomData: map[string]interface{}{"k1": "v1", "k2": 1}, }, }, }, @@ -302,6 +306,9 @@ var validMessages = []struct { "sound": "s", "thread-id": "t", "content-available": float64(1), + "mutable-content": float64(1), + "k1": "v1", + "k2": float64(1), }, }, }, @@ -471,6 +478,21 @@ var invalidMessages = []struct { }, want: "multiple alert specifications", }, + { + name: "APNSMultipleFieldSpecifications", + req: &Message{ + APNS: &APNSConfig{ + Payload: &APNSPayload{ + Aps: &Aps{ + Category: "category", + CustomData: map[string]interface{}{"category": "category"}, + }, + }, + }, + Topic: "topic", + }, + want: `multiple specifications for the key "category"`, + }, { name: "InvalidAPNSTitleLocArgs", req: &Message{ @@ -618,30 +640,72 @@ func TestSendError(t *testing.T) { client.fcmEndpoint = ts.URL cases := []struct { - resp string - want string + resp, want string + check func(error) bool }{ { - resp: "{}", - want: "http error status: 500; reason: server responded with an unknown error; response: {}", + resp: "{}", + want: "http error status: 500; reason: server responded with an unknown error; response: {}", + check: IsUnknown, }, { - resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", - want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + resp: "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument; details: test error", + check: IsInvalidArgument, }, { resp: "{\"error\": {\"status\": \"NOT_FOUND\", \"message\": \"test error\"}}", - want: "http error status: 500; reason: app instance has been unregistered; code: registration-token-not-registered", + want: "http error status: 500; reason: app instance has been unregistered; code: registration-token-not-registered; " + + "details: test error", + check: IsRegistrationTokenNotRegistered, }, { - resp: "not json", - want: "http error status: 500; reason: server responded with an unknown error; response: not json", + resp: "{\"error\": {\"status\": \"QUOTA_EXCEEDED\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: messaging service quota exceeded; code: message-rate-exceeded; " + + "details: test error", + check: IsMessageRateExceeded, + }, + { + resp: "{\"error\": {\"status\": \"UNAVAILABLE\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: backend servers are temporarily unavailable; code: server-unavailable; " + + "details: test error", + check: IsServerUnavailable, + }, + { + resp: "{\"error\": {\"status\": \"INTERNAL\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: backend servers encountered an unknown internl error; code: internal-error; " + + "details: test error", + check: IsInternal, + }, + { + resp: "{\"error\": {\"status\": \"APNS_AUTH_ERROR\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: apns certificate or auth key was invalid; code: invalid-apns-credentials; " + + "details: test error", + check: IsInvalidAPNSCredentials, + }, + { + resp: "{\"error\": {\"status\": \"SENDER_ID_MISMATCH\", \"message\": \"test error\"}}", + want: "http error status: 500; reason: sender id does not match regisration token; code: mismatched-credential; " + + "details: test error", + check: IsMismatchedCredential, + }, + { + resp: `{"error": {"status": "INVALID_ARGUMENT", "message": "test error", "details": [` + + `{"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmErrorCode", "errorCode": "UNREGISTERED"}]}}`, + want: "http error status: 500; reason: app instance has been unregistered; code: registration-token-not-registered; " + + "details: test error", + check: IsRegistrationTokenNotRegistered, + }, + { + resp: "not json", + want: "http error status: 500; reason: server responded with an unknown error; response: not json", + check: IsUnknown, }, } for _, tc := range cases { resp = tc.resp name, err := client.Send(ctx, &Message{Topic: "topic"}) - if err == nil || err.Error() != tc.want { + if err == nil || err.Error() != tc.want || !tc.check(err) { t.Errorf("Send() = (%q, %v); want = (%q, %q)", name, err, "", tc.want) } } @@ -758,26 +822,34 @@ func TestTopicManagementError(t *testing.T) { client.iidEndpoint = ts.URL cases := []struct { - resp string - want string + resp, want string + check func(error) bool }{ { - resp: "{}", - want: "http error status: 500; reason: client encountered an unknown error; response: {}", + resp: "{}", + want: "http error status: 500; reason: client encountered an unknown error; response: {}", + check: IsUnknown, }, { - resp: "{\"error\": \"INVALID_ARGUMENT\"}", - want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + resp: "{\"error\": \"INVALID_ARGUMENT\"}", + want: "http error status: 500; reason: request contains an invalid argument; code: invalid-argument", + check: IsInvalidArgument, }, { - resp: "not json", - want: "http error status: 500; reason: client encountered an unknown error; response: not json", + resp: "{\"error\": \"TOO_MANY_TOPICS\"}", + want: "http error status: 500; reason: client exceeded the number of allowed topics; code: too-many-topics", + check: IsTooManyTopics, + }, + { + resp: "not json", + want: "http error status: 500; reason: client encountered an unknown error; response: not json", + check: IsUnknown, }, } for _, tc := range cases { resp = tc.resp tmr, err := client.SubscribeToTopic(ctx, []string{"id1"}, "topic") - if err == nil || err.Error() != tc.want { + if err == nil || err.Error() != tc.want || !tc.check(err) { t.Errorf("SubscribeToTopic() = (%q, %v); want = (%q, %q)", tmr, err, "", tc.want) } } diff --git a/messaging/messaging_utils.go b/messaging/messaging_utils.go index ffd4df95..8e0cb0e9 100644 --- a/messaging/messaging_utils.go +++ b/messaging/messaging_utils.go @@ -102,6 +102,12 @@ func validateAps(aps *Aps) error { if aps.Alert != nil && aps.AlertString != "" { return fmt.Errorf("multiple alert specifications") } + m := aps.standardFields() + for k := range aps.CustomData { + if _, contains := m[k]; contains { + return fmt.Errorf("multiple specifications for the key %q", k) + } + } return validateApsAlert(aps.Alert) } return nil diff --git a/storage/storage.go b/storage/storage.go index dbcd1303..78019184 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -13,7 +13,7 @@ // limitations under the License. // Package storage provides functions for accessing Google Cloud Storge buckets. -package storage +package storage // import "firebase.google.com/go/storage" import ( "errors"