Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for custom root CAs #185

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions pkg/common/webclient/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package webclient

import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
)

// client is a wrapper around http.Client with common notification service functionality
type client struct {
headers http.Header
indent string
httpClient http.Client
parse ParserFunc
write WriterFunc
}

// SetParser overrides the parser for the incoming response content
func (c *client) SetParser(parse ParserFunc) {
c.parse = parse
}

// SetWriter overrides the writer for the outgoing request content
func (c *client) SetWriter(write WriterFunc) {
c.write = write
}

// Headers return the default headers for requests
func (c *client) Headers() http.Header {
return c.headers
}

// HTTPClient returns the underlying http.WebClient used by the WebClient
func (c *client) HTTPClient() *http.Client {
return &c.httpClient
}

// Get fetches url using GET and unmarshals into the passed response
func (c *client) Get(url string, response interface{}) error {
return c.request(http.MethodGet, url, response, nil)
}

// Post sends a serialized representation of request and deserializes the result into response
func (c *client) Post(url string, request interface{}, response interface{}) error {
body, err := c.write(request)
if err != nil {
return fmt.Errorf("error creating payload: %v", err)
}

return c.request(http.MethodPost, url, response, bytes.NewReader(body))
}

// ErrorResponse tries to deserialize any response body into the supplied struct, returning whether successful or not
func (c *client) ErrorResponse(err error, response interface{}) bool {
jerr, isWebError := err.(ClientError)
if !isWebError {
return false
}

return c.parse([]byte(jerr.Body), response) == nil
}

func (c *client) request(method, url string, response interface{}, payload io.Reader) error {
req, err := http.NewRequest(method, url, payload)
if err != nil {
return 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("error sending payload: %v", err)
}

return c.parseResponse(res, response)
}

func (c *client) parseResponse(res *http.Response, response interface{}) error {
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)

if res.StatusCode >= 400 {
err = fmt.Errorf("got HTTP %v", res.Status)
}

if err == nil {
err = c.parse(body, response)
}

if err != nil {
if body == nil {
body = []byte{}
}
return ClientError{
StatusCode: res.StatusCode,
Body: string(body),
err: err,
}
}

return nil
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
package jsonclient_test
package webclient_test

import (
"errors"
"github.com/containrrr/shoutrrr/pkg/util/jsonclient"
"github.com/containrrr/shoutrrr/pkg/common/webclient"
"github.com/onsi/gomega/ghttp"
"net/http"
"testing"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

func TestJSONClient(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "JSONClient Suite")
}

var _ = Describe("JSONClient", func() {
var _ = Describe("WebClient", func() {
var server *ghttp.Server

BeforeEach(func() {
Expand All @@ -27,7 +21,7 @@ var _ = Describe("JSONClient", func() {
It("should return an error", func() {
server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "invalid json"))
res := &mockResponse{}
err := jsonclient.Get(server.URL(), &res)
err := webclient.GetJSON(server.URL(), &res)
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).To(MatchError("invalid character 'i' looking for beginning of value"))
Expect(res.Status).To(BeEmpty())
Expand All @@ -38,7 +32,7 @@ var _ = Describe("JSONClient", func() {
It("should return an error", func() {
server.AppendHandlers(ghttp.RespondWith(http.StatusOK, nil))
res := &mockResponse{}
err := jsonclient.Get(server.URL(), &res)
err := webclient.GetJSON(server.URL(), &res)
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).To(MatchError("unexpected end of JSON input"))
Expect(res.Status).To(BeEmpty())
Expand All @@ -48,7 +42,47 @@ var _ = Describe("JSONClient", func() {
It("should deserialize GET response", func() {
server.AppendHandlers(ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"}))
res := &mockResponse{}
err := jsonclient.Get(server.URL(), &res)
err := webclient.GetJSON(server.URL(), &res)
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).ToNot(HaveOccurred())
Expect(res.Status).To(Equal("OK"))
})

It("should update the parser and writer", func() {
client := webclient.NewJSONClient()
client.SetParser(func(raw []byte, v interface{}) error {
return errors.New(`mock parser`)
})
server.AppendHandlers(ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"}))
err := client.Get(server.URL(), nil)
Expect(err).To(MatchError(`mock parser`))

client.SetWriter(func(v interface{}) ([]byte, error) {
return nil, errors.New(`mock writer`)
})
err = client.Post(server.URL(), nil, nil)
Expect(err).To(MatchError(`error creating payload: mock writer`))
})

It("should unwrap serialized error responses", func() {
client := webclient.NewJSONClient()
err := webclient.ClientError{Body: `{"Status": "BadStuff"}`}
res := &mockResponse{}
Expect(client.ErrorResponse(err, res)).To(BeTrue())
Expect(res.Status).To(Equal(`BadStuff`))
})

It("should send any additional headers that has been added", func() {
server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyHeaderKV(`Authentication`, `you don't need to see my identification`),
ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"}),
),
)
client := webclient.NewJSONClient()
client.Headers().Set(`Authentication`, `you don't need to see my identification`)
res := &mockResponse{}
err := client.Get(server.URL(), &res)
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).ToNot(HaveOccurred())
Expect(res.Status).To(Equal("OK"))
Expand All @@ -66,22 +100,22 @@ var _ = Describe("JSONClient", func() {
ghttp.RespondWithJSONEncoded(http.StatusOK, &mockResponse{Status: "That's Numberwang!"})),
)

err := jsonclient.Post(server.URL(), &req, &res)
err := webclient.PostJSON(server.URL(), &req, &res)
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).ToNot(HaveOccurred())
Expect(res.Status).To(Equal("That's Numberwang!"))
})

It("should return error on error status responses", func() {
server.AppendHandlers(ghttp.RespondWith(404, "Not found!"))
err := jsonclient.Post(server.URL(), &mockRequest{}, &mockResponse{})
err := webclient.PostJSON(server.URL(), &mockRequest{}, &mockResponse{})
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).To(MatchError("got HTTP 404 Not Found"))
})

It("should return error on invalid request", func() {
server.AppendHandlers(ghttp.VerifyRequest("POST", "/"))
err := jsonclient.Post(server.URL(), func() {}, &mockResponse{})
err := webclient.PostJSON(server.URL(), func() {}, &mockResponse{})
Expect(server.ReceivedRequests()).Should(HaveLen(0))
Expect(err).To(MatchError("error creating payload: json: unsupported type: func()"))
})
Expand All @@ -93,10 +127,10 @@ var _ = Describe("JSONClient", func() {
ghttp.RespondWithJSONEncoded(http.StatusOK, res)),
)

err := jsonclient.Post(server.URL(), nil, &[]bool{})
err := webclient.PostJSON(server.URL(), nil, &[]bool{})
Expect(server.ReceivedRequests()).Should(HaveLen(1))
Expect(err).To(MatchError("json: cannot unmarshal object into Go value of type []bool"))
Expect(jsonclient.ErrorBody(err)).To(MatchJSON(`{"Status":"cool skirt"}`))
Expect(webclient.ErrorBody(err)).To(MatchJSON(`{"Status":"cool skirt"}`))
})
})

Expand All @@ -106,24 +140,24 @@ var _ = Describe("JSONClient", func() {
})
})

var _ = Describe("Error", func() {
var _ = Describe("ClientError", func() {
When("no internal error has been set", func() {
It("should return a generic message with status code", func() {
errorWithNoError := jsonclient.Error{StatusCode: http.StatusEarlyHints}
errorWithNoError := webclient.ClientError{StatusCode: http.StatusEarlyHints}
Expect(errorWithNoError.String()).To(Equal("unknown error (HTTP 103)"))
})
})
Describe("ErrorBody", func() {
When("passed a non-json error", func() {
It("should return an empty string", func() {
Expect(jsonclient.ErrorBody(errors.New("unrelated error"))).To(BeEmpty())
Expect(webclient.ErrorBody(errors.New("unrelated error"))).To(BeEmpty())
})
})
When("passed a jsonclient.Error", func() {
When("passed a jsonclient.ClientError", func() {
It("should return the request body from that error", func() {
errorBody := `{"error": "bad user"}`
jsonError := jsonclient.Error{Body: errorBody}
Expect(jsonclient.ErrorBody(jsonError)).To(MatchJSON(errorBody))
jsonError := webclient.ClientError{Body: errorBody}
Expect(webclient.ErrorBody(jsonError)).To(MatchJSON(errorBody))
})
})
})
Expand Down
14 changes: 7 additions & 7 deletions pkg/util/jsonclient/error.go → pkg/common/webclient/error.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
package jsonclient
package webclient

import "fmt"

// Error contains additional http/JSON details
type Error struct {
// ClientError contains additional http/JSON details
type ClientError struct {
StatusCode int
Body string
err error
}

func (je Error) Error() string {
func (je ClientError) Error() string {
return je.String()
}

func (je Error) String() string {
func (je ClientError) String() string {
if je.err == nil {
return fmt.Sprintf("unknown error (HTTP %v)", je.StatusCode)
}
return je.err.Error()
}

// ErrorBody returns the request body from an Error
// ErrorBody returns the request body from a ClientError
func ErrorBody(e error) string {
if jsonError, ok := e.(Error); ok {
if jsonError, ok := e.(ClientError); ok {
return jsonError.Body
}
return ""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package jsonclient
package webclient

import "net/http"
import (
"net/http"
)

type Client interface {
// WebClient ...
type WebClient interface {
Get(url string, response interface{}) error
Post(url string, request interface{}, response interface{}) error
Headers() http.Header
ErrorResponse(err error, response interface{}) bool
SetParser(ParserFunc)
SetWriter(WriterFunc)
HTTPClient() *http.Client
}
37 changes: 37 additions & 0 deletions pkg/common/webclient/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package webclient

import (
"encoding/json"
"net/http"
)

// JSONContentType is the default mime type for JSON
const JSONContentType = "application/json"

// DefaultJSONClient is the singleton instance of WebClient using http.DefaultClient
var DefaultJSONClient = NewJSONClient()

// GetJSON fetches url using GET and unmarshals into the passed response using DefaultJSONClient
func GetJSON(url string, response interface{}) error {
return DefaultJSONClient.Get(url, response)
}

// PostJSON sends request as JSON and unmarshals the response JSON into the supplied struct using DefaultJSONClient
func PostJSON(url string, request interface{}, response interface{}) error {
return DefaultJSONClient.Post(url, request, response)
}

// NewJSONClient returns a WebClient using the default http.Client and JSON serialization
func NewJSONClient() WebClient {
var c client
c = client{
headers: http.Header{
"Content-Type": []string{JSONContentType},
},
parse: json.Unmarshal,
write: func(v interface{}) ([]byte, error) {
return json.MarshalIndent(v, "", c.indent)
},
}
return &c
}
Loading