Skip to content

Commit

Permalink
perf(hass): ♻️ rework request logic
Browse files Browse the repository at this point in the history
- response interface now expects to be able to represent an error
- responses can unmarshal a the raw response into their own error type for later usage
- update response structs to satisfy new response interface
  • Loading branch information
joshuar committed Jul 7, 2024
1 parent 96bf97f commit 2031c88
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 34 deletions.
17 changes: 16 additions & 1 deletion internal/hass/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

//nolint:errname // structs are dual-purpose response and error
//revive:disable:unused-receiver
package hass

Expand All @@ -20,7 +21,8 @@ var ErrLoadPrefsFailed = errors.New("could not load preferences")

type Config struct {
Details *ConfigEntries
mu sync.Mutex
*APIError
mu sync.Mutex
}

type ConfigEntries struct {
Expand Down Expand Up @@ -69,6 +71,19 @@ func (c *Config) UnmarshalJSON(b []byte) error {
return nil
}

func (c *Config) UnmarshalError(data []byte) error {
err := json.Unmarshal(data, c.APIError)
if err != nil {
return fmt.Errorf("could not unmarshal: %w", err)
}

return nil
}

func (c *Config) Error() string {
return c.APIError.Error()
}

type configRequest struct{}

func (c *configRequest) RequestBody() json.RawMessage {
Expand Down
83 changes: 82 additions & 1 deletion internal/hass/mock_Response_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions internal/hass/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

//nolint:errname // structs are dual-purpose response and error
package hass

import (
Expand Down Expand Up @@ -93,6 +94,7 @@ func newRegistrationRequest(info *DeviceInfo, token string) *registrationRequest

type registrationResponse struct {
Details *RegistrationDetails
*APIError
}

func (r *registrationResponse) UnmarshalJSON(b []byte) error {
Expand All @@ -104,6 +106,19 @@ func (r *registrationResponse) UnmarshalJSON(b []byte) error {
return nil
}

func (r *registrationResponse) UnmarshalError(data []byte) error {
err := json.Unmarshal(data, r.APIError)
if err != nil {
return fmt.Errorf("could not unmarshal: %w", err)
}

return nil
}

func (r *registrationResponse) Error() string {
return r.APIError.Error()
}

//nolint:exhaustruct
func newRegistrationResponse() *registrationResponse {
return &registrationResponse{}
Expand Down
10 changes: 10 additions & 0 deletions internal/hass/registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,24 @@ var mockDevInfo = &DeviceInfo{
SupportsEncryption: false,
}

//nolint:errname
type failedResponse struct {
Details *RegistrationDetails
*APIError
}

func (r *failedResponse) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &r.Details)
}

func (r *failedResponse) UnmarshalError(b []byte) error {
return json.Unmarshal(b, r.APIError)
}

func (r *failedResponse) Error() string {
return r.APIError.Error()
}

// setup creates a context using a test http client and server which will
// return the given response when the ExecuteRequest function is called.
var setupTestServer = func(t *testing.T, response Response) *httptest.Server {
Expand Down
39 changes: 18 additions & 21 deletions internal/hass/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ var (
ErrInvalidURL = errors.New("invalid URL")
ErrInvalidClient = errors.New("invalid client")
ErrResponseMalformed = errors.New("malformed response")
ErrNoPrefs = errors.New("loading preferences failed")
defaultTimeout = 30 * time.Second
defaultRetry = func(r *resty.Response, _ error) bool {
ErrUnknown = errors.New("unknown error occurred")

defaultTimeout = 30 * time.Second
defaultRetry = func(r *resty.Response, _ error) bool {
return r.StatusCode() == http.StatusTooManyRequests
}
)
Expand Down Expand Up @@ -76,6 +77,8 @@ type Encrypted interface {
//go:generate moq -out mock_Response_test.go . Response
type Response interface {
json.Unmarshaler
UnmarshalError(data []byte) error
error
}

// ExecuteRequest sends an API request to Home Assistant. It supports either the
Expand All @@ -84,23 +87,17 @@ type Response interface {
// satisfy the PostRequest interface. To add authentication where required,
// satisfy the Auth interface. To send an encrypted request, satisfy the Secret
// interface.
//
//nolint:exhaustruct
func ExecuteRequest(ctx context.Context, client *resty.Client, url string, request any, response Response) error {
if client == nil {
return ErrInvalidClient
}

// ?: handle nil response here
var responseErr *APIError

var resp *resty.Response

var err error

webClient := client.R().
SetContext(ctx).
SetError(&responseErr)
SetContext(ctx)
if a, ok := request.(Authenticated); ok {
webClient = webClient.SetAuthToken(a.Auth())
}
Expand All @@ -123,6 +120,7 @@ func ExecuteRequest(ctx context.Context, client *resty.Client, url string, reque
resp, err = webClient.Get(url)
}

// If the client fails to send the request, return a wrapped error.
if err != nil {
return fmt.Errorf("could not send request: %w", err)
}
Expand All @@ -136,21 +134,20 @@ func ExecuteRequest(ctx context.Context, client *resty.Client, url string, reque
RawJSON("body", resp.Body()).
Msg("Response received.")

// If the response is an error code, unmarshal it with the error method.
if resp.IsError() {
if responseErr != nil {
responseErr.StatusCode = resp.StatusCode()

return responseErr
if err := response.UnmarshalError(resp.Body()); err != nil {
return ErrUnknown
}

return &APIError{
StatusCode: resp.StatusCode(),
Message: resp.Status(),
}
return response
}

if err := response.UnmarshalJSON(resp.Body()); err != nil {
return errors.Join(ErrResponseMalformed, err)
// Otherwise for a successful response, if the response body is not an empty
// string, unmarshal it.
if string(resp.Body()) != "" {
if err := response.UnmarshalJSON(resp.Body()); err != nil {
return errors.Join(ErrResponseMalformed, err)
}
}

return nil
Expand Down
18 changes: 8 additions & 10 deletions internal/hass/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ func TestExecuteRequest(t *testing.T) {
badPostReq := PostRequestMock{
RequestBodyFunc: func() json.RawMessage { return json.RawMessage(`{"field":"value"}`) },
}
badPostResp := &ResponseMock{
UnmarshalErrorFunc: func(_ []byte) error { return nil },
ErrorFunc: func() string { return "400 Bad Request" },
}

// badPostResp := &APIError{
// StatusCode: 400,
Expand Down Expand Up @@ -133,19 +137,13 @@ func TestExecuteRequest(t *testing.T) {
},
{
name: "invalid post request",
args: args{ctx: context.TODO(), client: NewDefaultHTTPClient(mockServer.URL), url: "/badPost", request: badPostReq, response: &ResponseMock{}},
want: &APIError{
StatusCode: 400,
Message: "400 Bad Request",
},
args: args{ctx: context.TODO(), client: NewDefaultHTTPClient(mockServer.URL), url: "/badPost", request: badPostReq, response: badPostResp},
want: badPostResp,
},
{
name: "invalid get request",
args: args{ctx: context.TODO(), client: NewDefaultHTTPClient(mockServer.URL), url: "/badGet", request: "anything", response: &ResponseMock{}},
want: &APIError{
StatusCode: 400,
Message: "400 Bad Request",
},
args: args{ctx: context.TODO(), client: NewDefaultHTTPClient(mockServer.URL), url: "/badGet", request: "anything", response: badPostResp},
want: badPostResp,
},
// {
// name: "badData",
Expand Down
Loading

0 comments on commit 2031c88

Please sign in to comment.