Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
18
pkg/util/docs.go
Normal file
18
pkg/util/docs.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/internal/meta"
|
||||
)
|
||||
|
||||
// DocsURL returns a full documentation URL for the current version of Shoutrrr with the path appended.
|
||||
// If the path contains a leading slash, it is stripped.
|
||||
func DocsURL(path string) string {
|
||||
// strip leading slash if present
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://nicholas-fedor.github.io/shoutrrr/%s/%s", meta.DocsVersion, path)
|
||||
}
|
233
pkg/util/generator/generator_common.go
Normal file
233
pkg/util/generator/generator_common.go
Normal file
|
@ -0,0 +1,233 @@
|
|||
package generator
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
)
|
||||
|
||||
// errInvalidFormat indicates an invalid user input format.
|
||||
var (
|
||||
errInvalidFormat = errors.New("invalid format")
|
||||
errRequired = errors.New("field is required")
|
||||
errNotANumber = errors.New("not a number")
|
||||
errInvalidBoolFormat = errors.New("answer must be yes or no")
|
||||
)
|
||||
|
||||
// ValidateFormat wraps a boolean validator to return an error on false results.
|
||||
func ValidateFormat(validator func(string) bool) func(string) error {
|
||||
return func(answer string) error {
|
||||
if validator(answer) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errInvalidFormat
|
||||
}
|
||||
}
|
||||
|
||||
// Required validates that the input contains at least one character.
|
||||
func Required(answer string) error {
|
||||
if answer == "" {
|
||||
return errRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDialog facilitates question/answer-based user interaction.
|
||||
type UserDialog struct {
|
||||
reader io.Reader
|
||||
writer io.Writer
|
||||
scanner *bufio.Scanner
|
||||
props map[string]string
|
||||
}
|
||||
|
||||
// NewUserDialog initializes a UserDialog with safe defaults.
|
||||
func NewUserDialog(reader io.Reader, writer io.Writer, props map[string]string) *UserDialog {
|
||||
if props == nil {
|
||||
props = map[string]string{}
|
||||
}
|
||||
|
||||
return &UserDialog{
|
||||
reader: reader,
|
||||
writer: writer,
|
||||
scanner: bufio.NewScanner(reader),
|
||||
props: props,
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends a message to the user.
|
||||
func (ud *UserDialog) Write(message string, v ...any) {
|
||||
if _, err := fmt.Fprintf(ud.writer, message, v...); err != nil {
|
||||
_, _ = fmt.Fprint(ud.writer, "failed to write to output: ", err, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Writelnf writes a formatted message to the user, completing a line.
|
||||
func (ud *UserDialog) Writelnf(format string, v ...any) {
|
||||
ud.Write(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Query prompts the user and returns regex groups if the input matches the validator pattern.
|
||||
func (ud *UserDialog) Query(prompt string, validator *regexp.Regexp, key string) []string {
|
||||
var groups []string
|
||||
|
||||
ud.QueryString(prompt, ValidateFormat(func(answer string) bool {
|
||||
groups = validator.FindStringSubmatch(answer)
|
||||
|
||||
return groups != nil
|
||||
}), key)
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// QueryAll prompts the user and returns multiple regex matches up to maxMatches.
|
||||
func (ud *UserDialog) QueryAll(
|
||||
prompt string,
|
||||
validator *regexp.Regexp,
|
||||
key string,
|
||||
maxMatches int,
|
||||
) [][]string {
|
||||
var matches [][]string
|
||||
|
||||
ud.QueryString(prompt, ValidateFormat(func(answer string) bool {
|
||||
matches = validator.FindAllStringSubmatch(answer, maxMatches)
|
||||
|
||||
return matches != nil
|
||||
}), key)
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// QueryString prompts the user and returns the answer if it passes the validator.
|
||||
func (ud *UserDialog) QueryString(prompt string, validator func(string) error, key string) string {
|
||||
if validator == nil {
|
||||
validator = func(string) error { return nil }
|
||||
}
|
||||
|
||||
answer, foundProp := ud.props[key]
|
||||
if foundProp {
|
||||
err := validator(answer)
|
||||
colAnswer := format.ColorizeValue(answer, false)
|
||||
colKey := format.ColorizeProp(key)
|
||||
|
||||
if err == nil {
|
||||
ud.Writelnf("Using prop value %v for %v", colAnswer, colKey)
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
ud.Writelnf("Supplied prop value %v is not valid for %v: %v", colAnswer, colKey, err)
|
||||
}
|
||||
|
||||
for {
|
||||
ud.Write("%v ", prompt)
|
||||
color.Set(color.FgHiWhite)
|
||||
|
||||
if !ud.scanner.Scan() {
|
||||
if err := ud.scanner.Err(); err != nil {
|
||||
ud.Writelnf(err.Error())
|
||||
|
||||
continue
|
||||
}
|
||||
// Input closed, return an empty string
|
||||
return ""
|
||||
}
|
||||
|
||||
answer = ud.scanner.Text()
|
||||
|
||||
color.Unset()
|
||||
|
||||
if err := validator(answer); err != nil {
|
||||
ud.Writelnf("%v", err)
|
||||
ud.Writelnf("")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
// QueryStringPattern prompts the user and returns the answer if it matches the regex pattern.
|
||||
func (ud *UserDialog) QueryStringPattern(
|
||||
prompt string,
|
||||
validator *regexp.Regexp,
|
||||
key string,
|
||||
) string {
|
||||
if validator == nil {
|
||||
panic("validator cannot be nil")
|
||||
}
|
||||
|
||||
return ud.QueryString(prompt, func(s string) error {
|
||||
if validator.MatchString(s) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errInvalidFormat
|
||||
}, key)
|
||||
}
|
||||
|
||||
// QueryInt prompts the user and returns the answer as an integer if parseable.
|
||||
func (ud *UserDialog) QueryInt(prompt string, key string, bitSize int) int64 {
|
||||
validator := regexp.MustCompile(`^((0x|#)([0-9a-fA-F]+))|(-?[0-9]+)$`)
|
||||
|
||||
var value int64
|
||||
|
||||
ud.QueryString(prompt, func(answer string) error {
|
||||
groups := validator.FindStringSubmatch(answer)
|
||||
if len(groups) < 1 {
|
||||
return errNotANumber
|
||||
}
|
||||
|
||||
number := groups[0]
|
||||
|
||||
base := 0
|
||||
if groups[2] == "#" {
|
||||
// Explicitly treat #ffa080 as hexadecimal
|
||||
base = 16
|
||||
number = groups[3]
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
value, err = strconv.ParseInt(number, base, bitSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing integer from %q: %w", answer, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, key)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryBool prompts the user and returns the answer as a boolean if parseable.
|
||||
func (ud *UserDialog) QueryBool(prompt string, key string) bool {
|
||||
var value bool
|
||||
|
||||
ud.QueryString(prompt, func(answer string) error {
|
||||
parsed, ok := format.ParseBool(answer, false)
|
||||
if ok {
|
||||
value = parsed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w: use %v or %v",
|
||||
errInvalidBoolFormat,
|
||||
format.ColorizeTrue("yes"),
|
||||
format.ColorizeFalse("no"),
|
||||
)
|
||||
}, key)
|
||||
|
||||
return value
|
||||
}
|
184
pkg/util/generator/generator_test.go
Normal file
184
pkg/util/generator/generator_test.go
Normal file
|
@ -0,0 +1,184 @@
|
|||
package generator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/gbytes"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util/generator"
|
||||
)
|
||||
|
||||
func TestGenerator(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Generator Suite")
|
||||
}
|
||||
|
||||
var (
|
||||
client *generator.UserDialog
|
||||
userOut *gbytes.Buffer
|
||||
userIn *gbytes.Buffer
|
||||
)
|
||||
|
||||
func mockTyped(a ...any) {
|
||||
_, _ = fmt.Fprint(userOut, a...)
|
||||
_, _ = fmt.Fprint(userOut, "\n")
|
||||
}
|
||||
|
||||
func dumpBuffers() {
|
||||
for _, line := range strings.Split(string(userIn.Contents()), "\n") {
|
||||
_, _ = fmt.Fprint(ginkgo.GinkgoWriter, "> ", line, "\n")
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(userOut.Contents()), "\n") {
|
||||
_, _ = fmt.Fprint(ginkgo.GinkgoWriter, "< ", line, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("GeneratorCommon", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
userOut = gbytes.NewBuffer()
|
||||
userIn = gbytes.NewBuffer()
|
||||
userInMono := colorable.NewNonColorable(userIn)
|
||||
client = generator.NewUserDialog(
|
||||
userOut,
|
||||
userInMono,
|
||||
map[string]string{"propKey": "propVal"},
|
||||
)
|
||||
})
|
||||
|
||||
ginkgo.It("reprompt upon invalid answers", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
go func() {
|
||||
answer <- client.QueryString("name:", generator.Required, "")
|
||||
}()
|
||||
|
||||
mockTyped("")
|
||||
mockTyped("Normal Human Name")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`name: `))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`field is required`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`name: `))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("Normal Human Name")))
|
||||
})
|
||||
|
||||
ginkgo.It("should accept any input when validator is nil", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
go func() {
|
||||
answer <- client.QueryString("name:", nil, "")
|
||||
}()
|
||||
mockTyped("")
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.BeEmpty()))
|
||||
})
|
||||
|
||||
ginkgo.It("should use predefined prop value if key is present", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
go func() {
|
||||
answer <- client.QueryString("name:", generator.Required, "propKey")
|
||||
}()
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("propVal")))
|
||||
})
|
||||
|
||||
ginkgo.Describe("Query", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan []string)
|
||||
query := "pick foo or bar:"
|
||||
go func() {
|
||||
answer <- client.Query(query, regexp.MustCompile("(foo|bar)"), "")
|
||||
}()
|
||||
|
||||
mockTyped("")
|
||||
mockTyped("foo")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`invalid format`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.ContainElement("foo")))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryAll", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan [][]string)
|
||||
query := "pick foo or bar:"
|
||||
go func() {
|
||||
answer <- client.QueryAll(query, regexp.MustCompile(`foo(ba[rz])`), "", -1)
|
||||
}()
|
||||
|
||||
mockTyped("foobar foobaz")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
var matches [][]string
|
||||
gomega.Eventually(answer).Should(gomega.Receive(&matches))
|
||||
gomega.Expect(matches).To(gomega.ContainElement([]string{"foobar", "bar"}))
|
||||
gomega.Expect(matches).To(gomega.ContainElement([]string{"foobaz", "baz"}))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryStringPattern", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
query := "type of bar:"
|
||||
go func() {
|
||||
answer <- client.QueryStringPattern(query, regexp.MustCompile(".*bar"), "")
|
||||
}()
|
||||
|
||||
mockTyped("foo")
|
||||
mockTyped("foobar")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`invalid format`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("foobar")))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryInt", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan int64)
|
||||
query := "number:"
|
||||
go func() {
|
||||
answer <- client.QueryInt(query, "", 64)
|
||||
}()
|
||||
|
||||
mockTyped("x")
|
||||
mockTyped("0x20")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`not a number`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal(int64(32))))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryBool", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan bool)
|
||||
query := "cool?"
|
||||
go func() {
|
||||
answer <- client.QueryBool(query, "")
|
||||
}()
|
||||
|
||||
mockTyped("maybe")
|
||||
mockTyped("y")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`answer must be yes or no`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.BeTrue()))
|
||||
})
|
||||
})
|
||||
})
|
37
pkg/util/jsonclient/error.go
Normal file
37
pkg/util/jsonclient/error.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package jsonclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Error contains additional HTTP/JSON details.
|
||||
type Error struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
err error
|
||||
}
|
||||
|
||||
// Error returns the string representation of the error.
|
||||
func (je Error) Error() string {
|
||||
return je.String()
|
||||
}
|
||||
|
||||
// String provides a human-readable description of the error.
|
||||
func (je Error) String() string {
|
||||
if je.err == nil {
|
||||
return fmt.Sprintf("unknown error (HTTP %v)", je.StatusCode)
|
||||
}
|
||||
|
||||
return je.err.Error()
|
||||
}
|
||||
|
||||
// ErrorBody extracts the request body from an error if it’s a jsonclient.Error.
|
||||
func ErrorBody(e error) string {
|
||||
var jsonError Error
|
||||
if errors.As(e, &jsonError) {
|
||||
return jsonError.Body
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
10
pkg/util/jsonclient/interface.go
Normal file
10
pkg/util/jsonclient/interface.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package jsonclient
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Client interface {
|
||||
Get(url string, response any) error
|
||||
Post(url string, request any, response any) error
|
||||
Headers() http.Header
|
||||
ErrorResponse(err error, response any) bool
|
||||
}
|
165
pkg/util/jsonclient/jsonclient.go
Normal file
165
pkg/util/jsonclient/jsonclient.go
Normal file
|
@ -0,0 +1,165 @@
|
|||
package jsonclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ContentType defines the default MIME type for JSON requests.
|
||||
const ContentType = "application/json"
|
||||
|
||||
// HTTPClientErrorThreshold specifies the status code threshold for client errors (400+).
|
||||
const HTTPClientErrorThreshold = 400
|
||||
|
||||
// ErrUnexpectedStatus indicates an unexpected HTTP response status.
|
||||
var (
|
||||
ErrUnexpectedStatus = errors.New("got unexpected HTTP status")
|
||||
)
|
||||
|
||||
// DefaultClient provides a singleton JSON client using http.DefaultClient.
|
||||
var DefaultClient = NewClient()
|
||||
|
||||
// Client wraps http.Client for JSON operations.
|
||||
type client struct {
|
||||
httpClient *http.Client
|
||||
headers http.Header
|
||||
indent string
|
||||
}
|
||||
|
||||
// Get fetches a URL using GET and unmarshals the response into the provided object using DefaultClient.
|
||||
func Get(url string, response any) error {
|
||||
if err := DefaultClient.Get(url, response); err != nil {
|
||||
return fmt.Errorf("getting JSON from %q: %w", url, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Post sends a request as JSON and unmarshals the response into the provided object using DefaultClient.
|
||||
func Post(url string, request any, response any) error {
|
||||
if err := DefaultClient.Post(url, request, response); err != nil {
|
||||
return fmt.Errorf("posting JSON to %q: %w", url, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewClient creates a new JSON client using the default http.Client.
|
||||
func NewClient() Client {
|
||||
return NewWithHTTPClient(http.DefaultClient)
|
||||
}
|
||||
|
||||
// NewWithHTTPClient creates a new JSON client using the specified http.Client.
|
||||
func NewWithHTTPClient(httpClient *http.Client) Client {
|
||||
return &client{
|
||||
httpClient: httpClient,
|
||||
headers: http.Header{
|
||||
"Content-Type": []string{ContentType},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Headers returns the default headers for requests.
|
||||
func (c *client) Headers() http.Header {
|
||||
return c.headers
|
||||
}
|
||||
|
||||
// Get fetches a URL using GET and unmarshals the response into the provided object.
|
||||
func (c *client) Get(url string, response any) error {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating GET request for %q: %w", url, err)
|
||||
}
|
||||
|
||||
for key, val := range c.headers {
|
||||
req.Header.Set(key, val[0])
|
||||
}
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("executing GET request to %q: %w", url, err)
|
||||
}
|
||||
|
||||
return parseResponse(res, response)
|
||||
}
|
||||
|
||||
// Post sends a request as JSON and unmarshals the response into the provided object.
|
||||
func (c *client) Post(url string, request any, response any) error {
|
||||
var err error
|
||||
|
||||
var body []byte
|
||||
|
||||
if strReq, ok := request.(string); ok {
|
||||
// If the request is a string, pass it through without serializing
|
||||
body = []byte(strReq)
|
||||
} else {
|
||||
body, err = json.MarshalIndent(request, "", c.indent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling request to JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
url,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating POST request for %q: %w", url, err)
|
||||
}
|
||||
|
||||
for key, val := range c.headers {
|
||||
req.Header.Set(key, val[0])
|
||||
}
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending POST request to %q: %w", url, err)
|
||||
}
|
||||
|
||||
return parseResponse(res, response)
|
||||
}
|
||||
|
||||
// ErrorResponse checks if an error is a JSON error and unmarshals its body into the response.
|
||||
func (c *client) ErrorResponse(err error, response any) bool {
|
||||
var errMsg Error
|
||||
if errors.As(err, &errMsg) {
|
||||
return json.Unmarshal([]byte(errMsg.Body), response) == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseResponse parses the HTTP response and unmarshals it into the provided object.
|
||||
func parseResponse(res *http.Response, response any) error {
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
|
||||
if res.StatusCode >= HTTPClientErrorThreshold {
|
||||
err = fmt.Errorf("%w: %v", ErrUnexpectedStatus, res.Status)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, response)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if body == nil {
|
||||
body = []byte{}
|
||||
}
|
||||
|
||||
return Error{
|
||||
StatusCode: res.StatusCode,
|
||||
Body: string(body),
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
334
pkg/util/jsonclient/jsonclient_test.go
Normal file
334
pkg/util/jsonclient/jsonclient_test.go
Normal file
|
@ -0,0 +1,334 @@
|
|||
package jsonclient_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/ghttp"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient"
|
||||
)
|
||||
|
||||
func TestJSONClient(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "JSONClient Suite")
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("JSONClient", func() {
|
||||
var server *ghttp.Server
|
||||
var client jsonclient.Client
|
||||
|
||||
ginkgo.BeforeEach(func() {
|
||||
server = ghttp.NewServer()
|
||||
client = jsonclient.NewClient()
|
||||
})
|
||||
|
||||
ginkgo.When("the server returns an invalid JSON response", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "invalid json"))
|
||||
res := &mockResponse{}
|
||||
err := client.Get(server.URL(), res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("invalid character 'i' looking for beginning of value"))
|
||||
gomega.Expect(res.Status).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("the server returns an empty response", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
server.AppendHandlers(ghttp.RespondWith(http.StatusOK, nil))
|
||||
res := &mockResponse{}
|
||||
err := client.Get(server.URL(), res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).To(gomega.MatchError("unexpected end of JSON input"))
|
||||
gomega.Expect(res.Status).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.It("should deserialize GET response", func() {
|
||||
server.AppendHandlers(
|
||||
ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"}),
|
||||
)
|
||||
res := &mockResponse{}
|
||||
err := client.Get(server.URL(), res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("OK"))
|
||||
})
|
||||
|
||||
ginkgo.Describe("Top-level Functions", func() {
|
||||
ginkgo.It("should handle GET via DefaultClient", func() {
|
||||
server.AppendHandlers(
|
||||
ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "Default OK"}),
|
||||
)
|
||||
res := &mockResponse{}
|
||||
err := jsonclient.Get(server.URL(), res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("Default OK"))
|
||||
})
|
||||
|
||||
ginkgo.It("should handle POST via DefaultClient", func() {
|
||||
req := &mockRequest{Number: 10}
|
||||
res := &mockResponse{}
|
||||
server.AppendHandlers(ghttp.CombineHandlers(
|
||||
ghttp.VerifyRequest("POST", "/"),
|
||||
ghttp.VerifyJSONRepresenting(&req),
|
||||
ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "Default POST"})),
|
||||
)
|
||||
err := jsonclient.Post(server.URL(), req, res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("Default POST"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("POST", func() {
|
||||
ginkgo.It("should de-/serialize request and response", func() {
|
||||
req := &mockRequest{Number: 5}
|
||||
res := &mockResponse{}
|
||||
server.AppendHandlers(ghttp.CombineHandlers(
|
||||
ghttp.VerifyRequest("POST", "/"),
|
||||
ghttp.VerifyJSONRepresenting(&req),
|
||||
ghttp.RespondWithJSONEncoded(
|
||||
http.StatusOK,
|
||||
&mockResponse{Status: "That's Numberwang!"},
|
||||
),
|
||||
))
|
||||
err := client.Post(server.URL(), req, res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("That's Numberwang!"))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error on error status responses", func() {
|
||||
server.AppendHandlers(ghttp.RespondWith(http.StatusNotFound, "Not found!"))
|
||||
err := client.Post(server.URL(), &mockRequest{}, &mockResponse{})
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).To(gomega.MatchError("got unexpected HTTP status: 404 Not Found"))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error on invalid request", func() {
|
||||
server.AppendHandlers(ghttp.VerifyRequest("POST", "/"))
|
||||
err := client.Post(server.URL(), func() {}, &mockResponse{})
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.BeEmpty())
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("marshaling request to JSON: json: unsupported type: func()"))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error on invalid response type", func() {
|
||||
res := &mockResponse{Status: "cool skirt"}
|
||||
server.AppendHandlers(ghttp.CombineHandlers(
|
||||
ghttp.VerifyRequest("POST", "/"),
|
||||
ghttp.RespondWithJSONEncoded(http.StatusOK, res)),
|
||||
)
|
||||
err := client.Post(server.URL(), nil, &[]bool{})
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("json: cannot unmarshal object into Go value of type []bool"))
|
||||
gomega.Expect(jsonclient.ErrorBody(err)).To(gomega.MatchJSON(`{"Status":"cool skirt"}`))
|
||||
})
|
||||
|
||||
ginkgo.It("should handle string request without marshaling", func() {
|
||||
rawJSON := `{"Number": 42}`
|
||||
res := &mockResponse{}
|
||||
server.AppendHandlers(ghttp.CombineHandlers(
|
||||
ghttp.VerifyRequest("POST", "/"),
|
||||
ghttp.VerifyBody([]byte(rawJSON)),
|
||||
ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "String Worked"})),
|
||||
)
|
||||
err := client.Post(server.URL(), rawJSON, res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("String Worked"))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error when NewRequest fails", func() {
|
||||
err := client.Post("://invalid-url", &mockRequest{}, &mockResponse{})
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).
|
||||
To(gomega.ContainSubstring("creating POST request for \"://invalid-url\": parse \"://invalid-url\": missing protocol scheme"))
|
||||
})
|
||||
|
||||
ginkgo.It("should return error when http.Client.Do fails", func() {
|
||||
brokenClient := jsonclient.NewWithHTTPClient(&http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: func(_, _ string) (net.Conn, error) {
|
||||
return nil, errors.New("forced network error")
|
||||
},
|
||||
},
|
||||
})
|
||||
err := brokenClient.Post(server.URL(), &mockRequest{}, &mockResponse{})
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).
|
||||
To(gomega.ContainSubstring("sending POST request to \"" + server.URL() + "\": Post \"" + server.URL() + "\": forced network error"))
|
||||
})
|
||||
|
||||
ginkgo.It("should set multiple custom headers in request", func() {
|
||||
customClient := jsonclient.NewWithHTTPClient(&http.Client{})
|
||||
headers := customClient.Headers()
|
||||
headers.Set("X-Custom-Header", "CustomValue")
|
||||
headers.Set("X-Another-Header", "AnotherValue")
|
||||
|
||||
req := &mockRequest{Number: 99}
|
||||
res := &mockResponse{}
|
||||
server.AppendHandlers(ghttp.CombineHandlers(
|
||||
ghttp.VerifyRequest("POST", "/"),
|
||||
ghttp.VerifyHeader(http.Header{
|
||||
"Content-Type": []string{jsonclient.ContentType},
|
||||
"X-Custom-Header": []string{"CustomValue"},
|
||||
"X-Another-Header": []string{"AnotherValue"},
|
||||
}),
|
||||
ghttp.VerifyJSONRepresenting(&req),
|
||||
ghttp.RespondWithJSONEncoded(
|
||||
http.StatusOK,
|
||||
mockResponse{Status: "Headers Worked"},
|
||||
),
|
||||
))
|
||||
err := customClient.Post(server.URL(), req, res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("Headers Worked"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("Headers", func() {
|
||||
ginkgo.It("should return default headers with Content-Type", func() {
|
||||
headers := client.Headers()
|
||||
gomega.Expect(headers.Get("Content-Type")).To(gomega.Equal(jsonclient.ContentType))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("ErrorResponse", func() {
|
||||
ginkgo.It("should return false for non-jsonclient.Error", func() {
|
||||
res := &mockResponse{}
|
||||
result := client.ErrorResponse(errors.New("generic error"), res)
|
||||
gomega.Expect(result).To(gomega.BeFalse())
|
||||
gomega.Expect(res.Status).To(gomega.BeEmpty())
|
||||
})
|
||||
|
||||
ginkgo.It("should populate response from jsonclient.Error body", func() {
|
||||
res := &mockResponse{}
|
||||
jsonErr := jsonclient.Error{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: `{"Status": "Bad Request"}`,
|
||||
}
|
||||
result := client.ErrorResponse(jsonErr, res)
|
||||
gomega.Expect(result).To(gomega.BeTrue())
|
||||
gomega.Expect(res.Status).To(gomega.Equal("Bad Request"))
|
||||
})
|
||||
|
||||
ginkgo.It("should return false for invalid JSON in error body", func() {
|
||||
res := &mockResponse{}
|
||||
jsonErr := jsonclient.Error{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: "not json",
|
||||
}
|
||||
result := client.ErrorResponse(jsonErr, res)
|
||||
gomega.Expect(result).To(gomega.BeFalse())
|
||||
gomega.Expect(res.Status).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("Edge Cases", func() {
|
||||
ginkgo.It("should handle network failure in Get", func() {
|
||||
res := &mockResponse{}
|
||||
err := client.Get("http://127.0.0.1:54321", res)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).To(gomega.ContainSubstring("dial tcp"))
|
||||
gomega.Expect(res.Status).To(gomega.BeEmpty())
|
||||
})
|
||||
|
||||
ginkgo.It("should handle invalid JSON with success status", func() {
|
||||
server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "bad json"))
|
||||
res := &mockResponse{}
|
||||
err := client.Get(server.URL(), res)
|
||||
gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1))
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("invalid character 'b' looking for beginning of value"))
|
||||
gomega.Expect(res.Status).To(gomega.BeEmpty())
|
||||
})
|
||||
|
||||
ginkgo.It("should handle nil body in error response", func() {
|
||||
brokenClient := jsonclient.NewWithHTTPClient(&http.Client{
|
||||
Transport: &mockTransport{
|
||||
response: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Status: "400 Bad Request",
|
||||
Body: &failingReader{},
|
||||
Header: make(http.Header),
|
||||
},
|
||||
},
|
||||
})
|
||||
res := &mockResponse{}
|
||||
err := brokenClient.Get(server.URL(), res)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(err.Error()).
|
||||
To(gomega.ContainSubstring("got unexpected HTTP status: 400 Bad Request"))
|
||||
gomega.Expect(jsonclient.ErrorBody(err)).To(gomega.Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.AfterEach(func() {
|
||||
server.Close()
|
||||
})
|
||||
})
|
||||
|
||||
var _ = ginkgo.Describe("Error", func() {
|
||||
ginkgo.When("no internal error has been set", func() {
|
||||
ginkgo.It("should return a generic message with status code", func() {
|
||||
errorWithNoError := jsonclient.Error{StatusCode: http.StatusEarlyHints}
|
||||
gomega.Expect(errorWithNoError.String()).To(gomega.Equal("unknown error (HTTP 103)"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("ErrorBody", func() {
|
||||
ginkgo.When("passed a non-json error", func() {
|
||||
ginkgo.It("should return an empty string", func() {
|
||||
gomega.Expect(jsonclient.ErrorBody(errors.New("unrelated error"))).
|
||||
To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("passed a jsonclient.Error", func() {
|
||||
ginkgo.It("should return the request body from that error", func() {
|
||||
errorBody := `{"error": "bad user"}`
|
||||
jsonErr := jsonclient.Error{Body: errorBody}
|
||||
gomega.Expect(jsonclient.ErrorBody(jsonErr)).To(gomega.MatchJSON(errorBody))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockResponse struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
type mockRequest struct {
|
||||
Number int
|
||||
}
|
||||
|
||||
// mockTransport returns a predefined response.
|
||||
type mockTransport struct {
|
||||
response *http.Response
|
||||
}
|
||||
|
||||
func (mt *mockTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return mt.response, nil
|
||||
}
|
||||
|
||||
// failingReader simulates an io.Reader that fails on Read.
|
||||
type failingReader struct{}
|
||||
|
||||
func (fr *failingReader) Read([]byte) (int, error) {
|
||||
return 0, errors.New("simulated read failure")
|
||||
}
|
||||
|
||||
func (fr *failingReader) Close() error {
|
||||
return nil
|
||||
}
|
118
pkg/util/partition_message.go
Normal file
118
pkg/util/partition_message.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// ellipsis is the suffix appended to truncated strings.
|
||||
const ellipsis = " [...]"
|
||||
|
||||
// PartitionMessage splits a string into chunks of at most chunkSize runes.
|
||||
// It searches the last distance runes for a whitespace to improve readability,
|
||||
// adding chunks until reaching maxCount or maxTotal runes, returning the chunks
|
||||
// and the number of omitted runes.
|
||||
func PartitionMessage(
|
||||
input string,
|
||||
limits types.MessageLimit,
|
||||
distance int,
|
||||
) ([]types.MessageItem, int) {
|
||||
items := make([]types.MessageItem, 0, limits.ChunkCount-1)
|
||||
runes := []rune(input)
|
||||
chunkOffset := 0
|
||||
maxTotal := Min(len(runes), limits.TotalChunkSize)
|
||||
maxCount := limits.ChunkCount - 1
|
||||
|
||||
if len(input) == 0 {
|
||||
// If the message is empty, return an empty array
|
||||
return items, 0
|
||||
}
|
||||
|
||||
for range maxCount {
|
||||
// If no suitable split point is found, use the chunkSize
|
||||
chunkEnd := chunkOffset + limits.ChunkSize
|
||||
// ... and start next chunk directly after this one
|
||||
nextChunkStart := chunkEnd
|
||||
|
||||
if chunkEnd >= maxTotal {
|
||||
// The chunk is smaller than the limit, no need to search
|
||||
chunkEnd = maxTotal
|
||||
nextChunkStart = maxTotal
|
||||
} else {
|
||||
for r := range distance {
|
||||
rp := chunkEnd - r
|
||||
if runes[rp] == '\n' || runes[rp] == ' ' {
|
||||
// Suitable split point found
|
||||
chunkEnd = rp
|
||||
// Since the split is on a whitespace, skip it in the next chunk
|
||||
nextChunkStart = chunkEnd + 1
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, types.MessageItem{
|
||||
Text: string(runes[chunkOffset:chunkEnd]),
|
||||
})
|
||||
|
||||
chunkOffset = nextChunkStart
|
||||
if chunkOffset >= maxTotal {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return items, len(runes) - chunkOffset
|
||||
}
|
||||
|
||||
// Ellipsis truncates a string to maxLength characters, appending an ellipsis if needed.
|
||||
func Ellipsis(text string, maxLength int) string {
|
||||
if len(text) > maxLength {
|
||||
text = text[:maxLength-len(ellipsis)] + ellipsis
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// MessageItemsFromLines creates MessageItem batches compatible with the given limits.
|
||||
func MessageItemsFromLines(plain string, limits types.MessageLimit) [][]types.MessageItem {
|
||||
maxCount := limits.ChunkCount
|
||||
lines := strings.Split(plain, "\n")
|
||||
batches := make([][]types.MessageItem, 0)
|
||||
items := make([]types.MessageItem, 0, Min(maxCount, len(lines)))
|
||||
|
||||
totalLength := 0
|
||||
|
||||
for _, line := range lines {
|
||||
maxLen := limits.ChunkSize
|
||||
|
||||
if len(items) == maxCount || totalLength+maxLen > limits.TotalChunkSize {
|
||||
batches = append(batches, items)
|
||||
items = items[:0]
|
||||
}
|
||||
|
||||
runes := []rune(line)
|
||||
if len(runes) > maxLen {
|
||||
// Trim and add ellipsis
|
||||
runes = runes[:maxLen-len(ellipsis)]
|
||||
line = string(runes) + ellipsis
|
||||
}
|
||||
|
||||
if len(runes) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, types.MessageItem{
|
||||
Text: line,
|
||||
})
|
||||
|
||||
totalLength += len(runes)
|
||||
}
|
||||
|
||||
if len(items) > 0 {
|
||||
batches = append(batches, items)
|
||||
}
|
||||
|
||||
return batches
|
||||
}
|
193
pkg/util/partition_message_test.go
Normal file
193
pkg/util/partition_message_test.go
Normal file
|
@ -0,0 +1,193 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("Partition Message", func() {
|
||||
limits := types.MessageLimit{
|
||||
ChunkSize: 2000,
|
||||
TotalChunkSize: 6000,
|
||||
ChunkCount: 10,
|
||||
}
|
||||
ginkgo.When("given a message that exceeds the max length", func() {
|
||||
ginkgo.When("not splitting by lines", func() {
|
||||
ginkgo.It("should return a payload with chunked messages", func() {
|
||||
items, _ := testPartitionMessage(42)
|
||||
gomega.Expect(items[0].Text).To(gomega.HaveLen(1994))
|
||||
gomega.Expect(items[1].Text).To(gomega.HaveLen(1999))
|
||||
gomega.Expect(items[2].Text).To(gomega.HaveLen(205))
|
||||
})
|
||||
ginkgo.It("omit characters above total max", func() {
|
||||
items, _ := testPartitionMessage(62)
|
||||
gomega.Expect(items[0].Text).To(gomega.HaveLen(1994))
|
||||
gomega.Expect(items[1].Text).To(gomega.HaveLen(1999))
|
||||
gomega.Expect(items[2].Text).To(gomega.HaveLen(1999))
|
||||
gomega.Expect(items[3].Text).To(gomega.HaveLen(5))
|
||||
})
|
||||
ginkgo.It("should handle messages with a size modulus of chunksize", func() {
|
||||
items, _ := testPartitionMessage(20)
|
||||
// Last word fits in the chunk size
|
||||
gomega.Expect(items[0].Text).To(gomega.HaveLen(2000))
|
||||
|
||||
items, _ = testPartitionMessage(40)
|
||||
// Now the last word of the first chunk will be concatenated with
|
||||
// the first word of the second chunk, and so it does not fit in the chunk anymore
|
||||
gomega.Expect(items[0].Text).To(gomega.HaveLen(1994))
|
||||
gomega.Expect(items[1].Text).To(gomega.HaveLen(1999))
|
||||
gomega.Expect(items[2].Text).To(gomega.HaveLen(5))
|
||||
})
|
||||
ginkgo.When("the message is empty", func() {
|
||||
ginkgo.It("should return no items", func() {
|
||||
items, _ := testPartitionMessage(0)
|
||||
gomega.Expect(items).To(gomega.BeEmpty())
|
||||
})
|
||||
})
|
||||
ginkgo.When("given an input without whitespace", func() {
|
||||
ginkgo.It("should not crash, regardless of length", func() {
|
||||
unalignedLimits := types.MessageLimit{
|
||||
ChunkSize: 1997,
|
||||
ChunkCount: 11,
|
||||
TotalChunkSize: 5631,
|
||||
}
|
||||
|
||||
testString := ""
|
||||
for inputLen := 1; inputLen < 8000; inputLen++ {
|
||||
// add a rune to the string using a repeatable pattern (single digit hex of position)
|
||||
testString += strconv.FormatInt(int64(inputLen%16), 16)
|
||||
items, omitted := PartitionMessage(testString, unalignedLimits, 7)
|
||||
included := 0
|
||||
for ii, item := range items {
|
||||
expectedSize := unalignedLimits.ChunkSize
|
||||
|
||||
// The last chunk might be smaller than the preceding chunks
|
||||
if ii == len(items)-1 {
|
||||
// the chunk size is the remainder of, the total size,
|
||||
// or the max size, whatever is smallest,
|
||||
// and the previous chunk sizes
|
||||
chunkSize := Min(
|
||||
inputLen,
|
||||
unalignedLimits.TotalChunkSize,
|
||||
) % unalignedLimits.ChunkSize
|
||||
// if the "rest" of the runes needs another chunk
|
||||
if chunkSize > 0 {
|
||||
// expect the chunk to contain the "rest" of the runes
|
||||
expectedSize = chunkSize
|
||||
}
|
||||
// the last chunk should never be empty, so treat it as one of the full ones
|
||||
}
|
||||
|
||||
// verify the data, but only on the last chunk to reduce test time
|
||||
if ii == len(items)-1 {
|
||||
for ri, r := range item.Text {
|
||||
runeOffset := (len(item.Text) - ri) - 1
|
||||
runeVal, err := strconv.ParseInt(string(r), 16, 64)
|
||||
expectedLen := Min(inputLen, unalignedLimits.TotalChunkSize)
|
||||
expectedVal := (expectedLen - runeOffset) % 16
|
||||
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(runeVal).To(gomega.Equal(int64(expectedVal)))
|
||||
}
|
||||
}
|
||||
|
||||
included += len(item.Text)
|
||||
gomega.Expect(item.Text).To(gomega.HaveLen(expectedSize))
|
||||
}
|
||||
gomega.Expect(omitted + included).To(gomega.Equal(inputLen))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("splitting by lines", func() {
|
||||
ginkgo.It("should return a payload with chunked messages", func() {
|
||||
batches := testMessageItemsFromLines(18, limits, 2)
|
||||
items := batches[0]
|
||||
|
||||
gomega.Expect(items[0].Text).To(gomega.HaveLen(200))
|
||||
gomega.Expect(items[8].Text).To(gomega.HaveLen(200))
|
||||
})
|
||||
ginkgo.When("the message items exceed the limits", func() {
|
||||
ginkgo.It("should split items into multiple batches", func() {
|
||||
batches := testMessageItemsFromLines(21, limits, 2)
|
||||
|
||||
for b, chunks := range batches {
|
||||
fmt.Fprintf(ginkgo.GinkgoWriter, "Batch #%v: (%v chunks)\n", b, len(chunks))
|
||||
for c, chunk := range chunks {
|
||||
fmt.Fprintf(
|
||||
ginkgo.GinkgoWriter,
|
||||
" - Chunk #%v: (%v runes)\n",
|
||||
c,
|
||||
len(chunk.Text),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
gomega.Expect(batches).To(gomega.HaveLen(2))
|
||||
})
|
||||
})
|
||||
ginkgo.It("should trim characters above chunk size", func() {
|
||||
hundreds := 42
|
||||
repeat := 21
|
||||
batches := testMessageItemsFromLines(hundreds, limits, repeat)
|
||||
items := batches[0]
|
||||
|
||||
gomega.Expect(items[0].Text).To(gomega.HaveLen(limits.ChunkSize))
|
||||
gomega.Expect(items[1].Text).To(gomega.HaveLen(limits.ChunkSize))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const hundredChars = "this string is exactly (to the letter) a hundred characters long which will make the send func error"
|
||||
|
||||
// testMessageItemsFromLines generates message item batches from repeated text with line breaks.
|
||||
func testMessageItemsFromLines(
|
||||
hundreds int,
|
||||
limits types.MessageLimit,
|
||||
repeat int,
|
||||
) [][]types.MessageItem {
|
||||
builder := strings.Builder{}
|
||||
ri := 0
|
||||
|
||||
for range hundreds {
|
||||
builder.WriteString(hundredChars)
|
||||
|
||||
ri++
|
||||
if ri == repeat {
|
||||
builder.WriteRune('\n')
|
||||
|
||||
ri = 0
|
||||
}
|
||||
}
|
||||
|
||||
return MessageItemsFromLines(builder.String(), limits)
|
||||
}
|
||||
|
||||
// testPartitionMessage partitions repeated text into message items.
|
||||
func testPartitionMessage(hundreds int) ([]types.MessageItem, int) {
|
||||
limits := types.MessageLimit{
|
||||
ChunkSize: 2000,
|
||||
TotalChunkSize: 6000,
|
||||
ChunkCount: 10,
|
||||
}
|
||||
builder := strings.Builder{}
|
||||
|
||||
for range hundreds {
|
||||
builder.WriteString(hundredChars)
|
||||
}
|
||||
|
||||
items, omitted := PartitionMessage(builder.String(), limits, 100)
|
||||
contentSize := Min(hundreds*100, limits.TotalChunkSize)
|
||||
expectedOmitted := Max(0, (hundreds*100)-contentSize)
|
||||
|
||||
gomega.ExpectWithOffset(0, omitted).To(gomega.Equal(expectedOmitted))
|
||||
|
||||
return items, omitted
|
||||
}
|
27
pkg/util/util.go
Normal file
27
pkg/util/util.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Min returns the smallest of a and b.
|
||||
func Min(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Max returns the largest of a and b.
|
||||
func Max(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// DiscardLogger is a logger that discards any output written to it.
|
||||
var DiscardLogger = log.New(io.Discard, "", 0)
|
25
pkg/util/util_kinds.go
Normal file
25
pkg/util/util_kinds.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// IsUnsignedInt is a check against the unsigned integer types.
|
||||
func IsUnsignedInt(kind reflect.Kind) bool {
|
||||
return kind >= reflect.Uint && kind <= reflect.Uint64
|
||||
}
|
||||
|
||||
// IsSignedInt is a check against the signed decimal types.
|
||||
func IsSignedInt(kind reflect.Kind) bool {
|
||||
return kind >= reflect.Int && kind <= reflect.Int64
|
||||
}
|
||||
|
||||
// IsCollection is a check against slice and array.
|
||||
func IsCollection(kind reflect.Kind) bool {
|
||||
return kind == reflect.Slice || kind == reflect.Array
|
||||
}
|
||||
|
||||
// IsNumeric returns whether the Kind is one of the numeric ones.
|
||||
func IsNumeric(kind reflect.Kind) bool {
|
||||
return kind >= reflect.Int && kind <= reflect.Complex128
|
||||
}
|
15
pkg/util/util_numbers.go
Normal file
15
pkg/util/util_numbers.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
const hex int = 16
|
||||
|
||||
// StripNumberPrefix returns a number string with any base prefix stripped and it's corresponding base.
|
||||
// If no prefix was found, returns 0 to let strconv try to identify the base.
|
||||
func StripNumberPrefix(input string) (string, int) {
|
||||
if strings.HasPrefix(input, "#") {
|
||||
return input[1:], hex
|
||||
}
|
||||
|
||||
return input, 0
|
||||
}
|
105
pkg/util/util_test.go
Normal file
105
pkg/util/util_test.go
Normal file
|
@ -0,0 +1,105 @@
|
|||
package util_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/internal/meta"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util"
|
||||
)
|
||||
|
||||
func TestUtil(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Util Suite")
|
||||
}
|
||||
|
||||
const (
|
||||
a = 10
|
||||
b = 20
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("the util package", func() {
|
||||
ginkgo.When("calling function Min", func() {
|
||||
ginkgo.It("should return the smallest of two integers", func() {
|
||||
gomega.Expect(util.Min(a, b)).To(gomega.Equal(a))
|
||||
gomega.Expect(util.Min(b, a)).To(gomega.Equal(a))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("calling function Max", func() {
|
||||
ginkgo.It("should return the largest of two integers", func() {
|
||||
gomega.Expect(util.Max(a, b)).To(gomega.Equal(b))
|
||||
gomega.Expect(util.Max(b, a)).To(gomega.Equal(b))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("checking if a supplied kind is of the signed integer kind", func() {
|
||||
ginkgo.It("should be true if the kind is Int", func() {
|
||||
gomega.Expect(util.IsSignedInt(reflect.Int)).To(gomega.BeTrue())
|
||||
})
|
||||
ginkgo.It("should be false if the kind is String", func() {
|
||||
gomega.Expect(util.IsSignedInt(reflect.String)).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("checking if a supplied kind is of the unsigned integer kind", func() {
|
||||
ginkgo.It("should be true if the kind is Uint", func() {
|
||||
gomega.Expect(util.IsUnsignedInt(reflect.Uint)).To(gomega.BeTrue())
|
||||
})
|
||||
ginkgo.It("should be false if the kind is Int", func() {
|
||||
gomega.Expect(util.IsUnsignedInt(reflect.Int)).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("checking if a supplied kind is of the collection kind", func() {
|
||||
ginkgo.It("should be true if the kind is slice", func() {
|
||||
gomega.Expect(util.IsCollection(reflect.Slice)).To(gomega.BeTrue())
|
||||
})
|
||||
ginkgo.It("should be false if the kind is map", func() {
|
||||
gomega.Expect(util.IsCollection(reflect.Map)).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("calling function StripNumberPrefix", func() {
|
||||
ginkgo.It("should return the default base if none is found", func() {
|
||||
_, base := util.StripNumberPrefix("46")
|
||||
gomega.Expect(base).To(gomega.Equal(0))
|
||||
})
|
||||
ginkgo.It("should remove # prefix and return base 16 if found", func() {
|
||||
number, base := util.StripNumberPrefix("#ab")
|
||||
gomega.Expect(number).To(gomega.Equal("ab"))
|
||||
gomega.Expect(base).To(gomega.Equal(16))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("checking if a supplied kind is numeric", func() {
|
||||
ginkgo.It("should be true if supplied a constant integer", func() {
|
||||
gomega.Expect(util.IsNumeric(reflect.TypeOf(5).Kind())).To(gomega.BeTrue())
|
||||
})
|
||||
ginkgo.It("should be true if supplied a constant float", func() {
|
||||
gomega.Expect(util.IsNumeric(reflect.TypeOf(2.5).Kind())).To(gomega.BeTrue())
|
||||
})
|
||||
ginkgo.It("should be false if supplied a constant string", func() {
|
||||
gomega.Expect(util.IsNumeric(reflect.TypeOf("3").Kind())).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("calling function DocsURL", func() {
|
||||
ginkgo.It("should return the expected URL", func() {
|
||||
expectedBase := fmt.Sprintf(
|
||||
`https://nicholas-fedor.github.io/shoutrrr/%s/`,
|
||||
meta.DocsVersion,
|
||||
)
|
||||
gomega.Expect(util.DocsURL(``)).To(gomega.Equal(expectedBase))
|
||||
gomega.Expect(util.DocsURL(`services/logger`)).
|
||||
To(gomega.Equal(expectedBase + `services/logger`))
|
||||
})
|
||||
ginkgo.It("should strip the leading slash from the path", func() {
|
||||
gomega.Expect(util.DocsURL(`/foo`)).To(gomega.Equal(util.DocsURL(`foo`)))
|
||||
})
|
||||
})
|
||||
})
|
15
pkg/util/util_url.go
Normal file
15
pkg/util/util_url.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package util
|
||||
|
||||
import "net/url"
|
||||
|
||||
// URLUserPassword is a replacement/wrapper around url.UserPassword that treats empty string arguments as not specified.
|
||||
// If no user or password is specified, it returns nil (which serializes in url.URL to "").
|
||||
func URLUserPassword(user, password string) *url.Userinfo {
|
||||
if len(password) > 0 {
|
||||
return url.UserPassword(user, password)
|
||||
} else if len(user) > 0 {
|
||||
return url.User(user)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue