1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

18
pkg/util/docs.go Normal file
View 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)
}

View 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
}

View 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()))
})
})
})

View 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 its a jsonclient.Error.
func ErrorBody(e error) string {
var jsonError Error
if errors.As(e, &jsonError) {
return jsonError.Body
}
return ""
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
}