Reviewed-on: #5
This commit is contained in:
parent
048e7601e5
commit
79cc49d98e
|
@ -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
9
go.mod
|
@ -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
14
go.sum
|
@ -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=
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
|
@ -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...))
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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),
|
||||
))
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -5,4 +5,5 @@ package tools
|
|||
|
||||
import (
|
||||
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
|
||||
_ "github.com/maxbrunsfeld/counterfeiter/v6"
|
||||
)
|
||||
|
|
108
main.go
108
main.go
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Reference in New Issue