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

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
}