Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(service): Add Ntfy #469

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions service/ntfy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Ntfy

[![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/nikoksr/notify/service/ntfy)

## Usage

```go
package main

import (
"context"

"github.com/nikoksr/notify"
"github.com/nikoksr/notify/service/ntfy"
)

func main() {
// Create a ntfy service. You can use the
// `ntfy.NewWithServers` function to create a service with a custom server.
//ntfyService := ntfy.NewWithServers(ntfy.DefaultServerURL)

// Or use `ntfy.New` to create a service with the default server.
ntfyService := ntfy.New()

// Tell our notifier to use the bark service.
notify.UseServices(ntfyService)

content := `{
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/",
"actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
}`

// Send a test message.
err := notify.Send(
context.Background(),
"pushkar",
content,
)

if err != nil {
panic(err)
}
}
```
174 changes: 174 additions & 0 deletions service/ntfy/ntfy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package ntfy

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/pkg/errors"
)

// Service allow you to configure Ntfy service.
type Service struct {
client *http.Client
serverURLs []string
}

func defaultHTTPClient() *http.Client {
return &http.Client{
Timeout: 5 * time.Second,
}
}

// DefaultServerURL is the default server to use for the Ntfy service.
const DefaultServerURL = "https://ntfy.sh"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a need for this to be exported. We can keep the usage experience a little bit cleaner by making it non-exported.

Suggested change
const DefaultServerURL = "https://ntfy.sh"
const defaultServerURL = "https://ntfy.sh"


// normalizeServerURL normalizes the server URL. It prefixes it with https:// if it's not already and appends a slash
// if it's not already there. If the serverURL is empty, the DefaultServerURL is used. We're not validating the url here
// on purpose, we leave that to the http client.
func normalizeServerURL(serverURL string) string {
if serverURL == "" {
return DefaultServerURL
}

// Normalize the url
if !strings.HasPrefix(serverURL, "http") {
serverURL = "https://" + serverURL
}
if !strings.HasSuffix(serverURL, "/") {
serverURL = serverURL + "/"
}

return serverURL
}

// AddReceivers adds server URLs to the list of servers to use for sending messages.
func (s *Service) AddReceivers(serverURLs ...string) {
for _, serverURL := range serverURLs {
serverURL = normalizeServerURL(serverURL)
s.serverURLs = append(s.serverURLs, serverURL)
}
}

// NewWithServers returns a new instance of Ntfy service. You can use this service to send messages to Ntfy. You can
// specify the servers to send the messages to. By default, the service will use the default server
// (https://api.day.app/) if you don't specify any servers.
func NewWithServers(serverURLs ...string) *Service {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@svaloumas @pushkar803 I'm torn about this one. On one hand I like the convenience this offers (we're doing the same in the notify package), but on the other hand I see two things I dislike.

  1. No other service (to my knowledge) implements this type of function so far, which makes the library less predictable and intuitiv. So either, we adapt this pattern and extend all or most other services with the same function or we remove it from here.
  2. The inconsistent naming. We have a AddReceivers and NewWithServers, the latter calls AddReceivers internally. I think, in case we stick with it, this should be renamed to NewWithReceivers to stay consistent and intuitiv.
  3. A call to NewWithServers without is functionally identical to just calling New with an empty parameter list. So maybe, we can resolve my issue above (no. 2) by dropping NewWithServers entirely and extend New with a variadic receivers list. However, this again makes the package not follow our current behavioral standards, which makes it less predictable. Maybe it's better if we stay explicit with two seperate constructor functions as it currently is the case. Feedback on this please!

s := &Service{
client: defaultHTTPClient(),
}

if len(serverURLs) == 0 {
serverURLs = append(serverURLs, DefaultServerURL)
}
Comment on lines +66 to +68
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Length check is obsolete. An empty list will not alter the existsing serverURLs anyway.

Suggested change
if len(serverURLs) == 0 {
serverURLs = append(serverURLs, DefaultServerURL)
}
serverURLs = append(serverURLs, DefaultServerURL)


// Calling service.AddReceivers() instead of directly setting the serverURLs because we want to normalize the URLs.
s.AddReceivers(serverURLs...)

return s
}

// New returns a new instance of Ntfy service. You can use this service to send messages to Ntfy. By default, the
// service will use the default server (https://ntfy.sh).
func New() *Service {
return NewWithServers()
}

// postData is the data to send to the Ntfy server.
type postData struct {
Topic string `json:"topic"`
Message string `json:"message,omitempty"`
Title string `json:"title,omitempty"`
Tags []string `json:"tags,omitempty"`
Priority int `json:"priority,omitempty"`
Attach string `json:"attach,omitempty"`
Filename string `json:"filename,omitempty"`
Click string `json:"click,omitempty"`
Actions []struct {
Action string `json:"action"`
Label string `json:"label,omitempty"`
URL string `json:"url"`
} `json:"actions,omitempty"`
}

func (s *Service) send(ctx context.Context, serverURL, topic, content string) (err error) {
if serverURL == "" {
return errors.New("server url is empty")
}

bodyByte := []byte(content)

isJson := json.Valid(bodyByte)
if !isJson {
err := errors.New("Invalid JSON Body")
return err
}
Comment on lines +106 to +110
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential simplification.

Suggested change
isJson := json.Valid(bodyByte)
if !isJson {
err := errors.New("Invalid JSON Body")
return err
}
if !json.Valid(bodyByte) {
return errors.New("Invalid JSON Body")
}


var bodyj postData
if err := json.Unmarshal(bodyByte, &bodyj); err != nil {
err := errors.Wrap(err, "Invalid PostData structure")
return err
Comment on lines +114 to +115
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
err := errors.Wrap(err, "Invalid PostData structure")
return err
return errors.Wrap(err, "Invalid PostData structure")

}

if bodyj.Topic == "" {
bodyj.Topic = topic
}

messageJSON, err := json.Marshal(bodyj)
if err != nil {
return errors.Wrap(err, "marshal message")
}

// Create new request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, bytes.NewBuffer(messageJSON))
if err != nil {
return errors.Wrap(err, "create request")
}

req.Header.Set("Content-Type", "application/json; charset=utf-8")

// Send request
resp, err := s.client.Do(req)
if err != nil {
return errors.Wrap(err, "send request")
}
defer func() { _ = resp.Body.Close() }()

// Read response and verify success
result, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "read response")
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Ntfy returned status code %d: %s", resp.StatusCode, string(result))
}

return nil
}

// Send takes a message subject and a message content and sends them to Ntfy application.
func (s *Service) Send(ctx context.Context, subject, content string) error {
if s.client == nil {
return errors.New("client is nil")
}

for _, serverURL := range s.serverURLs {
select {
case <-ctx.Done():
return ctx.Err()
default:
err := s.send(ctx, serverURL, subject, content)
if err != nil {
return errors.Wrapf(err, "failed to send message to Ntfy server %q", serverURL)
}
}
}

return nil
}
125 changes: 125 additions & 0 deletions service/ntfy/ntfy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package ntfy

import (
"context"
"testing"

"github.com/stretchr/testify/require"
)

func TestNtfy_New(t *testing.T) {
t.Parallel()

assert := require.New(t)

service := New()
assert.NotNil(service)
}

func TestNtfy_NewWithServers(t *testing.T) {
t.Parallel()

assert := require.New(t)

service := NewWithServers()
assert.NotNil(service)

service = NewWithServers("xyz.com", "abc.com")
assert.NotNil(service)
}

func TestNtfy_AddReceivers(t *testing.T) {
t.Parallel()

assert := require.New(t)

service := New()
assert.NotNil(service)

rec := []string{"https://rec1.sh/", "https://rec2.sh/", "https://rec3.sh/"}
expected := []string{"https://ntfy.sh/", "https://rec1.sh/", "https://rec2.sh/", "https://rec3.sh/"}
service.AddReceivers(rec...)

assert.Equal(service.serverURLs, expected)
}

func TestNtfy_send(t *testing.T) {
t.Parallel()

assert := require.New(t)

service := New()
assert.NotNil(service)

service.client = defaultHTTPClient()

ctx := context.Background()
serverURL := DefaultServerURL
topic := "pushkar"
content := `{
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/",
"actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
}`

err := service.send(ctx, serverURL, topic, content)
assert.Nil(err)

err = service.send(ctx, "", topic, content)
assert.NotNil(err)

err = service.send(ctx, "xyz", topic, content)
assert.NotNil(err)

err = service.send(ctx, serverURL, topic, "sample_body")
assert.NotNil(err)

content = `{
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"priority": 4222,
}`

err = service.send(ctx, serverURL, topic, content)
assert.NotNil(err)

}

func TestNtfy_Send(t *testing.T) {
t.Parallel()

assert := require.New(t)

service := New()
assert.NotNil(service)

ctx := context.Background()
topic := "pushkar"
content := `{
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/",
"actions": [{ "action": "view", "label": "Admin panel", "url": "https://filesrv.lan/admin" }]
}`

err := service.Send(ctx, topic, content)
assert.Nil(err)

content = `{
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"priority": 4222,
}`

err = service.Send(ctx, topic, content)
assert.NotNil(err)
}
Comment on lines +46 to +125
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not completely misunderstanding this, we're pinging the actual endpoint here. In that case, we can't let this go through. We're solely relying on mocked tests for all other services and should do the same here.