(Feat): Initial Commit
This commit is contained in:
76
internal/push/provider/fcm_provider.go
Normal file
76
internal/push/provider/fcm_provider.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"google.golang.org/api/fcm/v1"
|
||||
"google.golang.org/api/googleapi"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type FCM struct {
|
||||
Config FCMConfig
|
||||
service *fcm.Service
|
||||
}
|
||||
|
||||
type FCMConfig struct {
|
||||
GoogleApplicationCredentials string `json:"-"` // sensitive
|
||||
ProjectID string
|
||||
ValidateOnly bool
|
||||
}
|
||||
|
||||
func NewFCM(config FCMConfig, opts ...option.ClientOption) (*FCM, error) {
|
||||
ctx := context.Background()
|
||||
fcmService, err := fcm.NewService(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create FCM service: %w", err)
|
||||
}
|
||||
|
||||
return &FCM{
|
||||
Config: config,
|
||||
service: fcmService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *FCM) GetProviderType() push.ProviderType {
|
||||
return push.ProviderTypeFCM
|
||||
}
|
||||
|
||||
func (p *FCM) Send(token string, title string, message string) push.ProviderSendResponse {
|
||||
// https: //godoc.org/google.golang.org/api/fcm/v1#SendMessageRequest
|
||||
// https://firebase.google.com/docs/cloud-messaging/send-message#rest
|
||||
messageRequest := &fcm.SendMessageRequest{
|
||||
ValidateOnly: p.Config.ValidateOnly,
|
||||
Message: &fcm.Message{
|
||||
Token: token,
|
||||
Notification: &fcm.Notification{
|
||||
Title: title,
|
||||
Body: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := p.service.Projects.Messages.Send("projects/"+p.Config.ProjectID, messageRequest).Do()
|
||||
valid := true
|
||||
if err != nil {
|
||||
// convert to original error and determine if the token was at fault
|
||||
var gErr *googleapi.Error
|
||||
if errors.As(err, &gErr) {
|
||||
valid = (gErr.Code != http.StatusNotFound && gErr.Code != http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
return push.ProviderSendResponse{
|
||||
Token: token,
|
||||
Valid: valid,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FCM) SendMulticast(tokens []string, title, message string) []push.ProviderSendResponse {
|
||||
return sendMulticastWithProvider(p, tokens, title, message)
|
||||
}
|
||||
13
internal/push/provider/helper.go
Normal file
13
internal/push/provider/helper.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package provider
|
||||
|
||||
import "allaboutapps.dev/aw/go-starter/internal/push"
|
||||
|
||||
func sendMulticastWithProvider(p push.Provider, tokens []string, title, message string) []push.ProviderSendResponse {
|
||||
responseSlice := make([]push.ProviderSendResponse, 0)
|
||||
|
||||
for _, token := range tokens {
|
||||
responseSlice = append(responseSlice, p.Send(token, title, message))
|
||||
}
|
||||
|
||||
return responseSlice
|
||||
}
|
||||
51
internal/push/provider/mock_provider.go
Normal file
51
internal/push/provider/mock_provider.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Mock struct {
|
||||
Type push.ProviderType
|
||||
}
|
||||
|
||||
func NewMock(providerType push.ProviderType) *Mock {
|
||||
return &Mock{
|
||||
Type: providerType,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Mock) GetProviderType() push.ProviderType {
|
||||
return p.Type
|
||||
}
|
||||
|
||||
const (
|
||||
expectedTokenLength = 40
|
||||
)
|
||||
|
||||
func (p *Mock) Send(token string, title string, message string) push.ProviderSendResponse {
|
||||
valid := true
|
||||
var err error
|
||||
if len(token) < expectedTokenLength {
|
||||
valid = false
|
||||
err = errors.New("invalid token")
|
||||
}
|
||||
|
||||
if title == "other error" {
|
||||
err = errors.New("other error")
|
||||
}
|
||||
|
||||
log.Info().Str("token", token).Str("title", title).Str("message", message).Msg("Mock Push Notification")
|
||||
|
||||
return push.ProviderSendResponse{
|
||||
Token: token,
|
||||
Valid: valid,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Mock) SendMulticast(tokens []string, title, message string) []push.ProviderSendResponse {
|
||||
return sendMulticastWithProvider(p, tokens, title, message)
|
||||
}
|
||||
107
internal/push/service.go
Normal file
107
internal/push/service.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
)
|
||||
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
ProviderTypeFCM ProviderType = "fcm"
|
||||
ProviderTypeAPN ProviderType = "apn"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
DB *sql.DB
|
||||
provider map[ProviderType]Provider
|
||||
}
|
||||
|
||||
type ProviderSendResponse struct {
|
||||
// token the message was sent to
|
||||
Token string
|
||||
|
||||
// flag to indicate if the token is still valid
|
||||
// not every error means that the token is invalid
|
||||
Valid bool
|
||||
|
||||
// ogiginal error
|
||||
Err error
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Send(token string, title string, message string) ProviderSendResponse
|
||||
SendMulticast(tokens []string, title, message string) []ProviderSendResponse
|
||||
GetProviderType() ProviderType
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Service {
|
||||
return &Service{
|
||||
DB: db,
|
||||
provider: make(map[ProviderType]Provider),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RegisterProvider(p Provider) {
|
||||
s.provider[p.GetProviderType()] = p
|
||||
}
|
||||
|
||||
func (s *Service) ResetProviders() {
|
||||
s.provider = make(map[ProviderType]Provider)
|
||||
}
|
||||
|
||||
func (s *Service) GetProviderCount() int {
|
||||
return len(s.provider)
|
||||
}
|
||||
|
||||
func (s *Service) SendToUser(ctx context.Context, user *dto.User, title string, message string) error {
|
||||
if s.GetProviderCount() < 1 {
|
||||
return errors.New("no provider found")
|
||||
}
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
for providerType, provider := range s.provider {
|
||||
// get all registered tokens for provider
|
||||
pushTokens, err := models.PushTokens(
|
||||
models.PushTokenWhere.Provider.EQ(string(providerType)),
|
||||
models.PushTokenWhere.UserID.EQ(user.ID),
|
||||
).All(ctx, s.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get push tokens: %w", err)
|
||||
}
|
||||
|
||||
var tokens []string
|
||||
for _, token := range pushTokens {
|
||||
tokens = append(tokens, token.Token)
|
||||
}
|
||||
|
||||
responseSlice := provider.SendMulticast(tokens, title, message)
|
||||
tokenToDelete := make([]string, 0)
|
||||
for _, res := range responseSlice {
|
||||
if res.Err != nil && res.Valid {
|
||||
log.Debug().Err(res.Err).Str("token", res.Token).Str("provider", string(provider.GetProviderType())).Msgf("Error while sending push message to provider with valid token.")
|
||||
}
|
||||
|
||||
if !res.Valid {
|
||||
tokenToDelete = append(tokenToDelete, res.Token)
|
||||
}
|
||||
}
|
||||
// delete invalid tokens
|
||||
_, err = models.PushTokens(
|
||||
models.PushTokenWhere.Token.IN(tokenToDelete),
|
||||
models.PushTokenWhere.UserID.EQ(user.ID),
|
||||
).DeleteAll(ctx, s.DB)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("provider", string(provider.GetProviderType())).Msg("Could not delete invalid tokens for provider")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
111
internal/push/service_test.go
Normal file
111
internal/push/service_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package push_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/mapper"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push/provider"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSendMessageSuccess(t *testing.T) {
|
||||
test.WithTestPusher(t, func(service *push.Service, db *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World")
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, int64(2), tokenCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendMessageSuccessWithGenericError(t *testing.T) {
|
||||
test.WithTestPusher(t, func(service *push.Service, db *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
// provoke error from mock provider
|
||||
err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "other error", "World")
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, int64(2), tokenCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendMessageWithInvalidToken(t *testing.T) {
|
||||
test.WithTestPusher(t, func(service *push.Service, db *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
user1InvalidPushToken := models.PushToken{
|
||||
ID: "55c37bc8-f245-40b3-bdef-14dee35b10bd",
|
||||
Token: "d5ded380-3285-4243-8a9c-72cc3f063fee",
|
||||
UserID: fix.User1.ID,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
}
|
||||
err := user1InvalidPushToken.Insert(ctx, db, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db)
|
||||
require.NoError(t, err2)
|
||||
require.Equal(t, int64(3), tokenCount)
|
||||
|
||||
err = service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World")
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenCount, err2 = fix.User1.PushTokens().Count(ctx, db)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, int64(2), tokenCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendMessageWithNoProvider(t *testing.T) {
|
||||
test.WithTestPusher(t, func(service *push.Service, db *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
service.ResetProviders()
|
||||
require.Equal(t, 0, service.GetProviderCount())
|
||||
|
||||
err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World")
|
||||
require.Error(t, err)
|
||||
|
||||
tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, int64(2), tokenCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendMessageWithMultipleProvider(t *testing.T) {
|
||||
test.WithTestPusher(t, func(service *push.Service, db *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
service.ResetProviders()
|
||||
require.Equal(t, 0, service.GetProviderCount())
|
||||
|
||||
mockProviderFCM := provider.NewMock(push.ProviderTypeFCM)
|
||||
mockProviderAPN := provider.NewMock(push.ProviderTypeAPN)
|
||||
service.RegisterProvider(mockProviderAPN)
|
||||
service.RegisterProvider(mockProviderFCM)
|
||||
|
||||
err := service.SendToUser(ctx, mapper.LocalUserToDTO(fix.User1).Ptr(), "Hello", "World")
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenCount, err2 := fix.User1.PushTokens().Count(ctx, db)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, int64(1), tokenCount)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user