165 lines
4.4 KiB
Go
165 lines
4.4 KiB
Go
// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com>
|
|
//
|
|
// This software is released under the MIT License.
|
|
// https://opensource.org/licenses/MIT
|
|
|
|
//revive:disable:max-public-structs
|
|
package hass
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/joshuar/go-hass-agent/internal/logging"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidURL = errors.New("invalid URL")
|
|
ErrInvalidClient = errors.New("invalid client")
|
|
ErrResponseMalformed = errors.New("malformed response")
|
|
ErrUnknown = errors.New("unknown error occurred")
|
|
|
|
defaultTimeout = 30 * time.Second
|
|
defaultRetry = func(r *resty.Response, _ error) bool {
|
|
return r.StatusCode() == http.StatusTooManyRequests
|
|
}
|
|
)
|
|
|
|
// APIError represents an error returned either by the HTTP layer or by the Home
|
|
// Assistant API. The StatusCode reflects the HTTP status code returned while
|
|
// Message and Code are additional and optional values returned from the Home
|
|
// Assistant API.
|
|
type APIError struct {
|
|
Code any `json:"code,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
StatusCode int `json:"-"`
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
switch {
|
|
case e.Code != nil:
|
|
return fmt.Sprintf("%v: %s", e.Code, e.Message)
|
|
case e.StatusCode > 0:
|
|
return fmt.Sprintf("Status: %d", e.StatusCode)
|
|
default:
|
|
return e.Message
|
|
}
|
|
}
|
|
|
|
// GetRequest is a HTTP GET request.
|
|
type GetRequest any
|
|
|
|
// PostRequest is a HTTP POST request with the request body provided by Body().
|
|
//
|
|
//go:generate moq -out mock_PostRequest_test.go . PostRequest
|
|
type PostRequest interface {
|
|
RequestBody() json.RawMessage
|
|
}
|
|
|
|
// Authenticated represents a request that requires passing an authentication
|
|
// header with the value returned by Auth().
|
|
type Authenticated interface {
|
|
Auth() string
|
|
}
|
|
|
|
// Encrypted represents a request that should be encrypted with the secret
|
|
// provided by Secret().
|
|
type Encrypted interface {
|
|
Secret() string
|
|
}
|
|
|
|
//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
|
|
// REST or WebSocket API. By default and at a minimum, request are sent as GET
|
|
// requests and need to satisfy the GetRequest interface. To send a POST,
|
|
// satisfy the PostRequest interface. To add authentication where required,
|
|
// satisfy the Auth interface. To send an encrypted request, satisfy the Secret
|
|
// interface.
|
|
func ExecuteRequest(ctx context.Context, client *resty.Client, url string, request any, response Response) error {
|
|
if client == nil {
|
|
return ErrInvalidClient
|
|
}
|
|
|
|
var resp *resty.Response
|
|
|
|
var err error
|
|
|
|
webClient := client.R().
|
|
SetContext(ctx)
|
|
if a, ok := request.(Authenticated); ok {
|
|
webClient = webClient.SetAuthToken(a.Auth())
|
|
}
|
|
|
|
switch req := request.(type) {
|
|
case PostRequest:
|
|
logging.FromContext(ctx).
|
|
LogAttrs(ctx, logging.LevelTrace,
|
|
"Sending request.",
|
|
slog.String("method", "POST"),
|
|
slog.String("body", string(req.RequestBody())),
|
|
slog.Time("sent_at", time.Now()))
|
|
|
|
resp, err = webClient.SetBody(req.RequestBody()).Post(url)
|
|
case GetRequest:
|
|
logging.FromContext(ctx).
|
|
LogAttrs(ctx, logging.LevelTrace,
|
|
"Sending request.",
|
|
slog.String("method", "GET"),
|
|
slog.Time("sent_at", time.Now()))
|
|
|
|
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)
|
|
}
|
|
|
|
logging.FromContext(ctx).
|
|
LogAttrs(ctx, logging.LevelTrace,
|
|
"Received response.",
|
|
slog.Int("statuscode", resp.StatusCode()),
|
|
slog.String("status", resp.Status()),
|
|
slog.String("protocol", resp.Proto()),
|
|
slog.Duration("time", resp.Time()),
|
|
slog.String("body", string(resp.Body())))
|
|
|
|
// If the response is an error code, unmarshal it with the error method.
|
|
if resp.IsError() {
|
|
if err := response.UnmarshalError(resp.Body()); err != nil {
|
|
return ErrUnknown
|
|
}
|
|
|
|
return response
|
|
}
|
|
// 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
|
|
}
|
|
|
|
func NewDefaultHTTPClient(url string) *resty.Client {
|
|
return resty.New().
|
|
SetTimeout(defaultTimeout).
|
|
AddRetryCondition(defaultRetry).
|
|
SetBaseURL(url)
|
|
}
|