Skip to content

Commit

Permalink
feat: add pushnotifications plugin
Browse files Browse the repository at this point in the history
Signed-off-by: Ales Verbic <[email protected]>
  • Loading branch information
verbotenj committed Oct 14, 2023
1 parent fe09318 commit 1d6e0e7
Show file tree
Hide file tree
Showing 13 changed files with 713 additions and 77 deletions.
2 changes: 1 addition & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func WithPort(port string) APIOption {
var apiInstance *APIv1
var once sync.Once

func NewAPI(debug bool, options ...APIOption) *APIv1 {
func New(debug bool, options ...APIOption) *APIv1 {
once.Do(func() {
apiInstance = &APIv1{
engine: ConfigureRouter(debug),
Expand Down
8 changes: 4 additions & 4 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import (

func TestRouteRegistration(t *testing.T) {
// Initialize the API and set it to debug mode for testing
apiInstance := api.NewAPI(true)
apiInstance := api.New(true)

// Check if Fcm implements APIRouteRegistrar and register its routes
// TODO: update this with actual plugin
fcmPlugin := &push.Fcm{}
if registrar, ok := interface{}(fcmPlugin).(api.APIRouteRegistrar); ok {
pushPlugin := &push.PushOutput{}
if registrar, ok := interface{}(pushPlugin).(api.APIRouteRegistrar); ok {
registrar.RegisterRoutes()
} else {
t.Fatal("push.Fcm does NOT implement APIRouteRegistrar")
t.Fatal("pushPlugin does NOT implement APIRouteRegistrar")
}

// Create a test request to one of the registered routes
Expand Down
6 changes: 5 additions & 1 deletion cmd/snek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func main() {
}

// Create API instance with debug disabled
apiInstance := api.NewAPI(false,
apiInstance := api.New(true,
api.WithGroup("/v1"),
api.WithPort("8080"))

Expand All @@ -130,6 +130,10 @@ func main() {
if output == nil {
logger.Fatalf("unknown output: %s", cfg.Output)
}
// Check if output plugin implements APIRouteRegistrar
if registrar, ok := interface{}(output).(api.APIRouteRegistrar); ok {
registrar.RegisterRoutes()
}
pipe.AddOutput(output)

// Start API after plugins are configured
Expand Down
99 changes: 99 additions & 0 deletions fcm/message.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package fcm

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/blinklabs-io/snek/internal/logging"
)

type Message struct {
MessageContent `json:"message"`
}

type MessageContent struct {
Token string `json:"token"`
Notification *NotificationContent `json:"notification,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}

type NotificationContent struct {
Title string `json:"title"`
Body string `json:"body"`
}

type MessageOption func(*MessageContent)

func WithData(data map[string]interface{}) MessageOption {
return func(m *MessageContent) {
m.Data = data
}
}

func WithNotification(title string, body string) MessageOption {
return func(m *MessageContent) {
m.Notification = &NotificationContent{
Title: title,
Body: body,
}
}
}

func NewMessage(token string, opts ...MessageOption) *Message {
if token == "" {
logging.GetLogger().Fatalf("Token is mandatory for FCM message")
}

msg := &Message{
MessageContent: MessageContent{
Token: token,
},
}
for _, opt := range opts {
opt(&msg.MessageContent)
}
return msg
}

func Send(accessToken string, projectId string, msg *Message) error {

fcmEndpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", projectId)

// Convert the message to JSON
payload, err := json.Marshal(msg)
if err != nil {
return err
}

fmt.Println(string(payload))

// Create a new HTTP request
req, err := http.NewRequest("POST", fcmEndpoint, bytes.NewBuffer(payload))
if err != nil {
return err
}

// Set headers
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")

// Execute the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

// Check for errors in the response
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return errors.New(string(body))
}

return nil
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ require (
github.com/kelseyhightower/envconfig v1.4.0
github.com/stretchr/testify v1.8.4
go.uber.org/zap v1.26.0
golang.org/x/oauth2 v0.11.0
gopkg.in/yaml.v2 v2.4.0
)

// XXX: uncomment when testing local changes to gouroboros
// replace github.com/blinklabs-io/gouroboros v0.52.0 => ../gouroboros

require (
cloud.google.com/go/compute v1.20.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
github.com/bytedance/sonic v1.10.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
Expand All @@ -29,6 +32,7 @@ require (
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
Expand All @@ -50,6 +54,7 @@ require (
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
21 changes: 19 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
github.com/blinklabs-io/gouroboros v0.54.0 h1:ZRp+L7Xb2wRn3N02a7EQ8jpBfqL6FKjnpauJdCiHEqE=
github.com/blinklabs-io/gouroboros v0.54.0/go.mod h1:ID2Lq1XtYrBvmk/y+yQiX45sZiV8n+urOmT0s46d2+U=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
Expand Down Expand Up @@ -37,9 +41,12 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
Expand Down Expand Up @@ -103,19 +110,29 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU=
golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
32 changes: 32 additions & 0 deletions output/push/api_routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2023 Blink Labs, LLC.
//
// 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 push

import (
"github.com/blinklabs-io/snek/api"
)

func (p *PushOutput) RegisterRoutes() {
apiInstance := api.GetInstance()

apiInstance.AddRoute("POST", "/fcm", storeFCMToken)
apiInstance.AddRoute("POST", "/fcm/", storeFCMToken)

apiInstance.AddRoute("GET", "/fcm/:token", readFCMToken)
apiInstance.AddRoute("GET", "/fcm/:token/", readFCMToken)

apiInstance.AddRoute("DELETE", "/fcm/:token", deleteFCMToken)
apiInstance.AddRoute("DELETE", "/fcm/:token/", deleteFCMToken)
}
130 changes: 130 additions & 0 deletions output/push/api_routes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package push_test

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/blinklabs-io/snek/api"
"github.com/blinklabs-io/snek/output/push"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)

func setupRouter() *gin.Engine {
apiInstance := api.New(true)
p := &push.PushOutput{}
p.RegisterRoutes() // This will internally get the API instance and register the routes for push
return apiInstance.Engine()
}

func TestStoreFCMToken(t *testing.T) {
router := setupRouter()

t.Run("Valid JSON input", func(t *testing.T) {
jsonStr := `{"FCMToken": "abcd1234"}`
req, _ := http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusCreated, w.Code)

tokens := push.GetFcmTokens()
assert.Contains(t, tokens, "abcd1234")
})

t.Run("Store 2 tokens JSON input", func(t *testing.T) {
jsonStr := `{"FCMToken": "abcd1234"}`
req, _ := http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusCreated, w.Code)

tokens := push.GetFcmTokens()
assert.Contains(t, tokens, "abcd1234")

jsonStr = `{"FCMToken": "abcd0000"}`
req, _ = http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusCreated, w.Code)

tokens = push.GetFcmTokens()
assert.Contains(t, tokens, "abcd0000")
})

t.Run("Invalid JSON input", func(t *testing.T) {
jsonStr := `{"invalid_field": "abcd1234"}`
req, _ := http.NewRequest("POST", "/fcm", strings.NewReader(jsonStr))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusBadRequest, w.Code)
})
}

func TestReadFCMToken(t *testing.T) {
router := setupRouter()

// Prepopulate the FCMTokens map for the read test
push.GetFcmTokens()["abcd1234"] = "abcd1234"

t.Run("Token exists", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/fcm/abcd1234", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "abcd1234")
})

t.Run("Token does not exist", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/fcm/nonexistenttoken", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusNotFound, w.Code)
})
}

func TestDeleteFCMToken(t *testing.T) {
router := setupRouter()

// Prepopulate the FCMTokens map for the delete test
push.GetFcmTokens()["abcd1234"] = "abcd1234"

t.Run("Token exists and is deleted", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/fcm/abcd1234", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusNoContent, w.Code)

tokens := push.GetFcmTokens()
_, exists := tokens["abcd1234"]
assert.False(t, exists, "Token should be deleted")
})

t.Run("Token does not exist", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/fcm/nonexistenttoken", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusNotFound, w.Code)
})

t.Run("Deleting already deleted token", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/fcm/abcd1234", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusNotFound, w.Code)
})
}
Loading

0 comments on commit 1d6e0e7

Please sign in to comment.