feat: add sonarr support (#5)
continuous-integration/drone/push Build is passing Details

Reviewed-on: #5
This commit is contained in:
Dawid Wysokiński 2022-07-18 04:57:13 +00:00
parent 048e7601e5
commit 79cc49d98e
22 changed files with 1073 additions and 28 deletions

View File

@ -5,6 +5,7 @@ set -o errexit -eo pipefail
cd ./internal/tools
go install \
github.com/golangci/golangci-lint/cmd/golangci-lint
github.com/golangci/golangci-lint/cmd/golangci-lint \
github.com/maxbrunsfeld/counterfeiter/v6
cd ../..

9
go.mod
View File

@ -2,10 +2,17 @@ module gitea.dwysokinski.me/Kichiyaki/notificationarr
go 1.18
require github.com/stretchr/testify v1.8.0
require (
github.com/go-chi/chi/v5 v5.0.7
github.com/google/go-cmp v0.5.8
github.com/google/uuid v1.3.0
github.com/stretchr/testify v1.8.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

14
go.sum
View File

@ -1,6 +1,17 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -8,8 +19,9 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -6,10 +6,16 @@ type ErrorCode uint8
const (
ErrorCodeUnknown ErrorCode = iota
ErrorCodeValidation
ErrorCodeIO // External I/O error such as network failure.
)
func (e ErrorCode) String() string {
switch e {
case ErrorCodeValidation:
return "validation-error"
case ErrorCodeIO:
return "io-error"
case ErrorCodeUnknown:
fallthrough
default:

View File

@ -13,6 +13,8 @@ func TestErrorCode_String(t *testing.T) {
t.Parallel()
assert.Equal(t, domain.ErrorCodeUnknown.String(), "internal-server-error")
assert.Equal(t, domain.ErrorCodeValidation.String(), "validation-error")
assert.Equal(t, domain.ErrorCodeIO.String(), "io-error")
}
func TestError(t *testing.T) {
@ -25,7 +27,7 @@ func TestError(t *testing.T) {
expectedMsg string
}{
{
name: "OK: WithCode, WithMessage",
name: "OK: WithCode(domain.ErrorCodeUnknown), WithMessage",
options: []domain.ErrorOption{
domain.WithCode(domain.ErrorCodeUnknown),
domain.WithMessage("err err"),
@ -35,7 +37,27 @@ func TestError(t *testing.T) {
expectedMsg: "err err",
},
{
name: "OK: WithCode, WithMessagef, WithErr",
name: "OK: WithCode(domain.ErrorCodeValidation), WithMessage",
options: []domain.ErrorOption{
domain.WithCode(domain.ErrorCodeValidation),
domain.WithMessage("err err"),
},
expectedCode: domain.ErrorCodeValidation,
expectedErr: nil,
expectedMsg: "err err",
},
{
name: "OK: WithCode(domain.ErrorCodeIO), WithMessage",
options: []domain.ErrorOption{
domain.WithCode(domain.ErrorCodeIO),
domain.WithMessage("err err"),
},
expectedCode: domain.ErrorCodeIO,
expectedErr: nil,
expectedMsg: "err err",
},
{
name: "OK: WithCode(domain.ErrorCodeUnknown), WithMessagef, WithErr",
options: []domain.ErrorOption{
domain.WithCode(domain.ErrorCodeUnknown),
domain.WithMessagef("xxx %d", 25),

View File

@ -0,0 +1,45 @@
package domain
type SonarrEventType string
const (
SonarrEventTypeDownload SonarrEventType = "Download"
SonarrEventTypeTest SonarrEventType = "Test"
)
var (
ErrUnsupportedSonarrEventType = NewError(WithCode(ErrorCodeValidation), WithMessage("unsupported event type"))
)
func NewSonarrEventType(s string) (SonarrEventType, error) {
conv := SonarrEventType(s)
switch conv {
case SonarrEventTypeDownload,
SonarrEventTypeTest:
return conv, nil
default:
return "", ErrUnsupportedSonarrEventType
}
}
func (s SonarrEventType) String() string {
return string(s)
}
type SonarrSeries struct {
ID int64
Title string
}
type SonarrEpisode struct {
ID int64
EpisodeNumber int16
SeasonNumber int16
Title string
}
type SonarrWebhookPayload struct {
EventType SonarrEventType
Series SonarrSeries
Episodes []SonarrEpisode
}

View File

@ -0,0 +1,33 @@
package domain_test
import (
"testing"
"github.com/stretchr/testify/assert"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
)
func TestNewSonarrEventType(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
events := []domain.SonarrEventType{domain.SonarrEventTypeDownload}
for _, ev := range events {
res, err := domain.NewSonarrEventType(ev.String())
assert.Equal(t, ev, res)
assert.NoError(t, err)
}
})
t.Run("ERR: invalid event type", func(t *testing.T) {
events := []string{"test1", "test2", "aaaa", "bbb"}
for _, ev := range events {
res, err := domain.NewSonarrEventType(ev)
assert.Zero(t, res)
assert.ErrorIs(t, err, domain.ErrUnsupportedSonarrEventType)
}
})
}

View File

@ -0,0 +1,115 @@
// Code generated by counterfeiter. DO NOT EDIT.
package mock
import (
"context"
"sync"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest"
)
type FakeSonarrService struct {
ProcessStub func(context.Context, domain.SonarrWebhookPayload) error
processMutex sync.RWMutex
processArgsForCall []struct {
arg1 context.Context
arg2 domain.SonarrWebhookPayload
}
processReturns struct {
result1 error
}
processReturnsOnCall map[int]struct {
result1 error
}
invocations map[string][][]interface{}
invocationsMutex sync.RWMutex
}
func (fake *FakeSonarrService) Process(arg1 context.Context, arg2 domain.SonarrWebhookPayload) error {
fake.processMutex.Lock()
ret, specificReturn := fake.processReturnsOnCall[len(fake.processArgsForCall)]
fake.processArgsForCall = append(fake.processArgsForCall, struct {
arg1 context.Context
arg2 domain.SonarrWebhookPayload
}{arg1, arg2})
stub := fake.ProcessStub
fakeReturns := fake.processReturns
fake.recordInvocation("Process", []interface{}{arg1, arg2})
fake.processMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeSonarrService) ProcessCallCount() int {
fake.processMutex.RLock()
defer fake.processMutex.RUnlock()
return len(fake.processArgsForCall)
}
func (fake *FakeSonarrService) ProcessCalls(stub func(context.Context, domain.SonarrWebhookPayload) error) {
fake.processMutex.Lock()
defer fake.processMutex.Unlock()
fake.ProcessStub = stub
}
func (fake *FakeSonarrService) ProcessArgsForCall(i int) (context.Context, domain.SonarrWebhookPayload) {
fake.processMutex.RLock()
defer fake.processMutex.RUnlock()
argsForCall := fake.processArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeSonarrService) ProcessReturns(result1 error) {
fake.processMutex.Lock()
defer fake.processMutex.Unlock()
fake.ProcessStub = nil
fake.processReturns = struct {
result1 error
}{result1}
}
func (fake *FakeSonarrService) ProcessReturnsOnCall(i int, result1 error) {
fake.processMutex.Lock()
defer fake.processMutex.Unlock()
fake.ProcessStub = nil
if fake.processReturnsOnCall == nil {
fake.processReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.processReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeSonarrService) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
fake.processMutex.RLock()
defer fake.processMutex.RUnlock()
copiedInvocations := map[string][][]interface{}{}
for key, value := range fake.invocations {
copiedInvocations[key] = value
}
return copiedInvocations
}
func (fake *FakeSonarrService) recordInvocation(key string, args []interface{}) {
fake.invocationsMutex.Lock()
defer fake.invocationsMutex.Unlock()
if fake.invocations == nil {
fake.invocations = map[string][][]interface{}{}
}
if fake.invocations[key] == nil {
fake.invocations[key] = [][]interface{}{}
}
fake.invocations[key] = append(fake.invocations[key], args)
}
var _ rest.SonarrService = new(FakeSonarrService)

56
internal/rest/rest.go Normal file
View File

@ -0,0 +1,56 @@
package rest
import (
"encoding/json"
"errors"
"net/http"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
)
//go:generate counterfeiter -generate
var (
errInternal = domain.NewError(
domain.WithCode(domain.ErrorCodeUnknown),
domain.WithMessage("internal server error"),
)
)
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type ErrorResponse struct {
Error APIError `json:"error"`
}
func renderErrorResponse(w http.ResponseWriter, err error) {
var convError domain.Error
if !errors.As(err, &convError) {
convError = errInternal
}
renderJSON(w, errorCodeToHTTPStatus(convError.Code()), ErrorResponse{
Error: APIError{
Code: convError.Code().String(),
Message: convError.Message(),
},
})
}
func errorCodeToHTTPStatus(code domain.ErrorCode) int {
switch code {
case domain.ErrorCodeValidation:
return http.StatusBadRequest
case domain.ErrorCodeIO:
return http.StatusServiceUnavailable
case domain.ErrorCodeUnknown:
fallthrough
default:
return http.StatusInternalServerError
}
}
func renderJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}

View File

@ -0,0 +1,33 @@
package rest_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
)
func doRequest(mux chi.Router, method, target string, body io.Reader) *http.Response {
rr := httptest.NewRecorder()
req := httptest.NewRequest(method, target, body)
mux.ServeHTTP(rr, req)
return rr.Result()
}
func assertJSONResponse(tb testing.TB, resp *http.Response, expectedStatus int, expected any, target any) {
tb.Helper()
assert.Equal(tb, expectedStatus, resp.StatusCode)
assert.NoError(tb, json.NewDecoder(resp.Body).Decode(target))
opts := cmp.Options{
cmpopts.IgnoreUnexported(time.Time{}),
}
assert.True(tb, cmp.Equal(expected, target, opts...), cmp.Diff(expected, target, opts...))
}

View File

@ -0,0 +1,92 @@
package rest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"github.com/go-chi/chi/v5"
)
//counterfeiter:generate -o internal/mock/sonarr_service.gen.go . SonarrService
type SonarrService interface {
Process(ctx context.Context, payload domain.SonarrWebhookPayload) error
}
type WebhookHandler struct {
sonarrSvc SonarrService
}
func NewWebhookHandler(sonarrSvc SonarrService) *WebhookHandler {
return &WebhookHandler{
sonarrSvc: sonarrSvc,
}
}
func (h *WebhookHandler) Register(r chi.Router) {
r.Post("/webhook/sonarr", h.handleSonarrWebhook)
}
type SonarrSeries struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type SonarrEpisode struct {
ID int64 `json:"id"`
EpisodeNumber int16 `json:"episodeNumber"`
SeasonNumber int16 `json:"seasonNumber"`
Title string `json:"title"`
}
type SonarrWebhookRequest struct {
EventType string `json:"eventType"`
Series SonarrSeries `json:"series"`
Episodes []SonarrEpisode `json:"episodes"`
}
func (h *WebhookHandler) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) {
var req SonarrWebhookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
renderErrorResponse(w, domain.NewError(
domain.WithErr(err),
domain.WithCode(domain.ErrorCodeValidation),
domain.WithMessage("invalid request body"),
))
return
}
eventType, err := domain.NewSonarrEventType(req.EventType)
if err != nil {
renderErrorResponse(w, fmt.Errorf("domain.NewSonarrEventType: %w", err))
return
}
payload := domain.SonarrWebhookPayload{
EventType: eventType,
Series: domain.SonarrSeries{
ID: req.Series.ID,
Title: req.Series.Title,
},
Episodes: make([]domain.SonarrEpisode, 0, len(req.Episodes)),
}
for _, ep := range req.Episodes {
payload.Episodes = append(payload.Episodes, domain.SonarrEpisode{
ID: ep.ID,
EpisodeNumber: ep.EpisodeNumber,
SeasonNumber: ep.SeasonNumber,
Title: ep.Title,
})
}
if err := h.sonarrSvc.Process(r.Context(), payload); err != nil {
renderErrorResponse(w, fmt.Errorf("SonarrService.Process: %w", err))
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -0,0 +1,131 @@
package rest_test
import (
"bytes"
"encoding/gob"
"encoding/json"
"io"
"net/http"
"testing"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"github.com/stretchr/testify/assert"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest/internal/mock"
"github.com/go-chi/chi/v5"
)
func TestWebhookHandler_Sonarr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setup func(svc *mock.FakeSonarrService)
body io.Reader
expectedStatus int
target any
expectedResponse any
}{
{
name: "OK: event type=Download",
setup: func(svc *mock.FakeSonarrService) {
svc.ProcessReturns(nil)
},
body: func() *bytes.Buffer {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(rest.SonarrWebhookRequest{
EventType: "Download",
Series: rest.SonarrSeries{
ID: 1,
Title: "Series 1",
},
Episodes: []rest.SonarrEpisode{
{
ID: 1,
EpisodeNumber: 1,
SeasonNumber: 1,
Title: "Ep 1",
},
},
})
return &buf
}(),
expectedStatus: http.StatusNoContent,
},
{
name: "ERR: only JSON is accepted",
setup: func(svc *mock.FakeSonarrService) {},
body: func() *bytes.Buffer {
var buf bytes.Buffer
_ = gob.NewEncoder(&buf).Encode(rest.SonarrWebhookRequest{
EventType: "Download",
Series: rest.SonarrSeries{
ID: 1,
Title: "Series 1",
},
Episodes: []rest.SonarrEpisode{
{
ID: 1,
EpisodeNumber: 1,
SeasonNumber: 1,
Title: "Ep 1",
},
},
})
return &buf
}(),
expectedStatus: http.StatusBadRequest,
target: &rest.ErrorResponse{},
expectedResponse: &rest.ErrorResponse{
Error: rest.APIError{
Code: domain.ErrorCodeValidation.String(),
Message: "invalid request body",
},
},
},
{
name: "ERR: unsupported event type",
setup: func(svc *mock.FakeSonarrService) {},
body: func() *bytes.Buffer {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(rest.SonarrWebhookRequest{
EventType: "xxxx",
})
return &buf
}(),
expectedStatus: http.StatusBadRequest,
target: &rest.ErrorResponse{},
expectedResponse: &rest.ErrorResponse{
Error: rest.APIError{
Code: domain.ErrUnsupportedSonarrEventType.Code().String(),
Message: domain.ErrUnsupportedSonarrEventType.Message(),
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
svc := mock.FakeSonarrService{}
if tt.setup != nil {
tt.setup(&svc)
}
router := chi.NewRouter()
rest.NewWebhookHandler(&svc).Register(router)
resp := doRequest(router, http.MethodPost, "/webhook/sonarr", tt.body)
defer assert.NoError(t, resp.Body.Close())
if tt.target != nil && tt.expectedResponse != nil {
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
} else {
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
}
})
}
}

View File

@ -0,0 +1,116 @@
// Code generated by counterfeiter. DO NOT EDIT.
package mock
import (
"context"
"sync"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service"
)
type FakePublisher struct {
PublishStub func(context.Context, string, string) error
publishMutex sync.RWMutex
publishArgsForCall []struct {
arg1 context.Context
arg2 string
arg3 string
}
publishReturns struct {
result1 error
}
publishReturnsOnCall map[int]struct {
result1 error
}
invocations map[string][][]interface{}
invocationsMutex sync.RWMutex
}
func (fake *FakePublisher) Publish(arg1 context.Context, arg2 string, arg3 string) error {
fake.publishMutex.Lock()
ret, specificReturn := fake.publishReturnsOnCall[len(fake.publishArgsForCall)]
fake.publishArgsForCall = append(fake.publishArgsForCall, struct {
arg1 context.Context
arg2 string
arg3 string
}{arg1, arg2, arg3})
stub := fake.PublishStub
fakeReturns := fake.publishReturns
fake.recordInvocation("Publish", []interface{}{arg1, arg2, arg3})
fake.publishMutex.Unlock()
if stub != nil {
return stub(arg1, arg2, arg3)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakePublisher) PublishCallCount() int {
fake.publishMutex.RLock()
defer fake.publishMutex.RUnlock()
return len(fake.publishArgsForCall)
}
func (fake *FakePublisher) PublishCalls(stub func(context.Context, string, string) error) {
fake.publishMutex.Lock()
defer fake.publishMutex.Unlock()
fake.PublishStub = stub
}
func (fake *FakePublisher) PublishArgsForCall(i int) (context.Context, string, string) {
fake.publishMutex.RLock()
defer fake.publishMutex.RUnlock()
argsForCall := fake.publishArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
}
func (fake *FakePublisher) PublishReturns(result1 error) {
fake.publishMutex.Lock()
defer fake.publishMutex.Unlock()
fake.PublishStub = nil
fake.publishReturns = struct {
result1 error
}{result1}
}
func (fake *FakePublisher) PublishReturnsOnCall(i int, result1 error) {
fake.publishMutex.Lock()
defer fake.publishMutex.Unlock()
fake.PublishStub = nil
if fake.publishReturnsOnCall == nil {
fake.publishReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.publishReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakePublisher) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
fake.publishMutex.RLock()
defer fake.publishMutex.RUnlock()
copiedInvocations := map[string][][]interface{}{}
for key, value := range fake.invocations {
copiedInvocations[key] = value
}
return copiedInvocations
}
func (fake *FakePublisher) recordInvocation(key string, args []interface{}) {
fake.invocationsMutex.Lock()
defer fake.invocationsMutex.Unlock()
if fake.invocations == nil {
fake.invocations = map[string][][]interface{}{}
}
if fake.invocations[key] == nil {
fake.invocations[key] = [][]interface{}{}
}
fake.invocations[key] = append(fake.invocations[key], args)
}
var _ service.Publisher = new(FakePublisher)

61
internal/service/ntfy.go Normal file
View File

@ -0,0 +1,61 @@
package service
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
)
type Ntfy struct {
client *http.Client
url string
topic string
username string
password string
}
func NewNtfy(client *http.Client, url string, topic string, username string, password string) *Ntfy {
return &Ntfy{
client: client,
url: url,
topic: topic,
username: username,
password: password,
}
}
func (n *Ntfy) Publish(ctx context.Context, title, message string) error {
// initialize request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.url+"/"+n.topic, strings.NewReader(message))
if err != nil {
return fmt.Errorf("http.NewRequestWithContext: %w", err)
}
// set required headers
req.Header.Set("Title", title)
if n.username != "" && n.password != "" {
req.SetBasicAuth(n.username, n.password)
}
// send request
resp, err := n.client.Do(req)
if err != nil {
return fmt.Errorf("client.Do: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
_, _ = io.Copy(io.Discard, resp.Body) // discard body, as it is not needed
if resp.StatusCode != http.StatusOK {
return domain.NewError(
domain.WithCode(domain.ErrorCodeIO),
domain.WithMessagef("ntfy returned unexpected status code: %d", resp.StatusCode),
)
}
return nil
}

View File

@ -0,0 +1,98 @@
package service_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service"
"github.com/stretchr/testify/assert"
)
func TestNtfy_Publish(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
tests := []struct {
name string
topic string
title string
message string
username string
password string
}{
{
name: "without authorization",
topic: "topicc",
title: "title",
message: "msg",
username: "",
password: "",
},
{
name: "with authorization",
topic: "topicc",
title: "title",
message: "msg",
username: "uname",
password: "pwd",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path[1:] != tt.topic {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Title") != tt.title {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("invalid title"))
return
}
if uname, pwd, _ := r.BasicAuth(); uname != tt.username || pwd != tt.password {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("invalid username or password"))
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
err := service.NewNtfy(srv.Client(), srv.URL, tt.topic, tt.username, tt.password).
Publish(context.Background(), tt.title, tt.message)
assert.NoError(t, err)
})
}
})
t.Run("ERR: status code != 200", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}))
defer srv.Close()
err := service.NewNtfy(srv.Client(), srv.URL, "topic", "", "").
Publish(context.Background(), "title", "msg")
assert.ErrorIs(t, err, domain.NewError(
domain.WithCode(domain.ErrorCodeIO),
domain.WithMessagef("ntfy returned unexpected status code: %d", http.StatusNotImplemented),
))
})
}

View File

@ -0,0 +1,10 @@
package service
import "context"
//go:generate counterfeiter -generate
//counterfeiter:generate -o internal/mock/publisher.gen.go . Publisher
type Publisher interface {
Publish(ctx context.Context, title, message string) error
}

View File

@ -0,0 +1,58 @@
package service
import (
"context"
"fmt"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
)
type Sonarr struct {
publisher Publisher
}
func NewSonaar(publisher Publisher) *Sonarr {
return &Sonarr{
publisher: publisher,
}
}
func (s *Sonarr) Process(ctx context.Context, payload domain.SonarrWebhookPayload) error {
for _, ep := range payload.Episodes {
title, err := s.buildTitle(payload.EventType, payload.Series, ep)
if err != nil {
return fmt.Errorf("buildTitle: %w", err)
}
msg, err := s.buildMessage(payload.EventType, payload.Series, ep)
if err != nil {
return fmt.Errorf("buildMessage: %w", err)
}
if err := s.publisher.Publish(ctx, title, msg); err != nil {
return fmt.Errorf("Publisher.Publish: %w", err)
}
}
return nil
}
func (s *Sonarr) buildTitle(evType domain.SonarrEventType, series domain.SonarrSeries, _ domain.SonarrEpisode) (string, error) {
switch evType {
case domain.SonarrEventTypeDownload,
domain.SonarrEventTypeTest:
return series.Title + " - New episode (Sonarr)", nil
default:
return "", domain.ErrUnsupportedSonarrEventType
}
}
func (s *Sonarr) buildMessage(evType domain.SonarrEventType, _ domain.SonarrSeries, ep domain.SonarrEpisode) (string, error) {
switch evType {
case domain.SonarrEventTypeDownload,
domain.SonarrEventTypeTest:
return fmt.Sprintf("S%d.E%d %s", ep.SeasonNumber, ep.EpisodeNumber, ep.Title), nil
default:
return "", domain.ErrUnsupportedSonarrEventType
}
}

View File

@ -0,0 +1,75 @@
package service_test
import (
"context"
"fmt"
"math/rand"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
"github.com/google/uuid"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service/internal/mock"
)
func TestSonarr_Process(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
for _, evType := range [...]domain.SonarrEventType{
domain.SonarrEventTypeDownload,
domain.SonarrEventTypeTest,
} {
t.Run("event type="+evType.String(), func(t *testing.T) {
t.Parallel()
publisher := &mock.FakePublisher{}
publisher.PublishReturns(nil)
payload := generateSonarrWebhookPayload(evType)
err := service.NewSonaar(publisher).Process(context.Background(), payload)
assert.NoError(t, err)
require.Equal(t, len(payload.Episodes), publisher.PublishCallCount())
for i, ep := range payload.Episodes {
_, title, msg := publisher.PublishArgsForCall(i)
assert.Equal(t, payload.Series.Title+" - New episode (Sonarr)", title)
assert.Equal(t, fmt.Sprintf("S%d.E%d %s", ep.SeasonNumber, ep.EpisodeNumber, ep.Title), msg)
}
})
}
})
}
func generateSonarrWebhookPayload(ev domain.SonarrEventType) domain.SonarrWebhookPayload {
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
numEps := r.Int31n(19) + 1
eps := make([]domain.SonarrEpisode, 0, numEps)
for i := int32(0); i < numEps; i++ {
eps = append(eps, domain.SonarrEpisode{
ID: r.Int63(),
EpisodeNumber: int16(r.Int31n(100)),
SeasonNumber: int16(r.Int31n(100)),
Title: uuid.NewString(),
})
}
return domain.SonarrWebhookPayload{
EventType: ev,
Series: domain.SonarrSeries{
ID: r.Int63(),
Title: uuid.NewString(),
},
Episodes: eps,
}
}

View File

@ -2,7 +2,10 @@ module gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/tools
go 1.18
require github.com/golangci/golangci-lint v1.46.2
require (
github.com/golangci/golangci-lint v1.46.2
github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0
)
require (
4d63.com/gochecknoglobals v0.1.0 // indirect

View File

@ -533,6 +533,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0 h1:rBhB9Rls+yb8kA4x5a/cWxOufWfXt24E+kq4YlbGj3g=
github.com/maxbrunsfeld/counterfeiter/v6 v6.5.0/go.mod h1:fJ0UAZc1fx3xZhU4eSHQDJ1ApFmTVhp5VTpV9tm2ogg=
github.com/mbilski/exhaustivestruct v1.2.0 h1:wCBmUnSYufAHO6J4AVWY6ff+oxWxsVFrwgOdMUQePUo=
github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aksxSUOUy+nvtVEfzXc=
github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg=
@ -573,7 +575,6 @@ github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 h1:4kuARK6Y6FxaNu/BnU2OAaLF86eTVhP2hjTB6iMvItA=
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nishanths/exhaustive v0.7.11 h1:xV/WU3Vdwh5BUH4N06JNUznb6d5zhRPOnlgCrpNYNKA=
github.com/nishanths/exhaustive v0.7.11/go.mod h1:gX+MP7DWMKJmNa1HfMozK+u04hQd3na9i0hyqf3/dOI=
@ -689,6 +690,7 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/sagikazarmark/crypt v0.5.0/go.mod h1:l+nzl7KWh51rpzp2h7t4MZWyiEWdhNpOAnclKvg+mdA=
github.com/sanposhiho/wastedassign/v2 v2.0.6 h1:+6/hQIHKNJAUixEj6EmOngGIisyeI+T3335lYTyxRoA=
github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI=
github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/securego/gosec/v2 v2.11.0 h1:+PDkpzR41OI2jrw1q6AdXZCbsNGNGT7pQjal0H0cArI=
github.com/securego/gosec/v2 v2.11.0/go.mod h1:SX8bptShuG8reGC0XS09+a4H2BoWSJi+fscA+Pulbpo=
@ -1365,8 +1367,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=

View File

@ -5,4 +5,5 @@ package tools
import (
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
_ "github.com/maxbrunsfeld/counterfeiter/v6"
)

108
main.go
View File

@ -2,32 +2,40 @@ package main
import (
"context"
"io"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
const (
defaultPort = "9234"
readTimeout = 2 * time.Second
readHeaderTimeout = 2 * time.Second
writeTimeout = 2 * time.Second
idleTimeout = 2 * time.Second
serverShutdownTimeout = 10 * time.Second
defaultPort = "9234"
readTimeout = 2 * time.Second
readHeaderTimeout = 2 * time.Second
writeTimeout = 2 * time.Second
idleTimeout = 2 * time.Second
serverShutdownTimeout = 10 * time.Second
ntfyDefaultUrl = "https://ntfy.sh"
publisherDefaultTimeout = 5 * time.Second
)
func main() {
srv := newServer()
srv, err := newServer()
if err != nil {
log.Fatalln("newServer:", err)
}
go func(srv *http.Server) {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalln("srv.ListenAndServe:", err)
}
}(srv)
go startServer(srv)
log.Println("Server is listening on the port", defaultPort)
@ -35,23 +43,83 @@ func main() {
ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), serverShutdownTimeout)
defer cancelShutdown()
err := srv.Shutdown(ctxShutdown)
err = srv.Shutdown(ctxShutdown)
if err != nil {
log.Println("srv.Shutdown:", err)
}
}
func newServer() *http.Server {
func newServer() (*http.Server, error) {
router, err := newRouter()
if err != nil {
return nil, fmt.Errorf("newRouter: %w", err)
}
return &http.Server{
Addr: ":" + defaultPort,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(os.Stdout, r.Body)
w.WriteHeader(http.StatusOK)
}),
Addr: ":" + defaultPort,
Handler: router,
ReadTimeout: readTimeout,
ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}, nil
}
func newRouter() (*chi.Mux, error) {
sonarrSvc, err := newSonarrService()
if err != nil {
return nil, fmt.Errorf("newSonarrService: %w", err)
}
r := chi.NewRouter()
r.Use(
middleware.RealIP,
middleware.RequestLogger(&middleware.DefaultLogFormatter{
NoColor: true,
Logger: log.Default(),
}),
middleware.Recoverer,
middleware.Heartbeat("/health"),
)
rest.NewWebhookHandler(sonarrSvc).Register(r)
return r, nil
}
func newSonarrService() (*service.Sonarr, error) {
url := os.Getenv("SONARR_NTFY_URL")
if url == "" {
url = ntfyDefaultUrl
}
topic := os.Getenv("SONARR_NTFY_TOPIC")
if topic == "" {
return nil, errors.New(`env "SONARR_NTFY_TOPIC" is required`)
}
timeout := os.Getenv("SONARR_NTFY_REQ_TIMEOUT")
parsedTimeout := publisherDefaultTimeout
var err error
if timeout != "" {
parsedTimeout, err = time.ParseDuration(timeout)
}
if err != nil {
return nil, fmt.Errorf(`os.Getenv("SONARR_NTFY_REQ_TIMEOUT"): %w`, err)
}
return service.NewSonaar(
service.NewNtfy(
&http.Client{Timeout: parsedTimeout},
url,
topic,
os.Getenv("SONARR_NTFY_USERNAME"),
os.Getenv("SONARR_NTFY_PASSWORD"),
),
), nil
}
func startServer(srv *http.Server) {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalln("srv.ListenAndServe:", err)
}
}