263 lines
7.3 KiB
Go
263 lines
7.3 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
maxResponseSize = 2 << 20 // 2 MiB
|
|
defaultTimeout = 15 * time.Second
|
|
defaultSessionCookieName = "gardomatic_session"
|
|
)
|
|
|
|
// Client is safe for concurrent use. A client configured with WithSessions
|
|
// owns one cookie jar and must therefore only be shared by callers that are
|
|
// meant to share the same login session.
|
|
type Client struct {
|
|
baseURL *url.URL
|
|
httpClient *http.Client
|
|
bearerToken string
|
|
headers http.Header
|
|
cookieName string
|
|
}
|
|
|
|
type config struct {
|
|
httpClient *http.Client
|
|
bearerToken string
|
|
headers http.Header
|
|
sessions bool
|
|
initialCookies []*http.Cookie
|
|
cookieName string
|
|
}
|
|
|
|
// Option configures a Client.
|
|
type Option func(*config) error
|
|
|
|
// WithHTTPClient supplies the HTTP client used for requests. The client is
|
|
// shallow-copied, so Client never changes the caller's value.
|
|
func WithHTTPClient(httpClient *http.Client) Option {
|
|
return func(cfg *config) error {
|
|
if httpClient == nil {
|
|
return errors.New("client: HTTP client must not be nil")
|
|
}
|
|
cfg.httpClient = httpClient
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithBearerToken authenticates every request using a bearer token.
|
|
func WithBearerToken(token string) Option {
|
|
return func(cfg *config) error {
|
|
if strings.TrimSpace(token) == "" {
|
|
return errors.New("client: bearer token must not be empty")
|
|
}
|
|
cfg.bearerToken = token
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithHeader adds a header to every request. Authorization and Cookie should
|
|
// be configured using WithBearerToken and WithSessions instead.
|
|
func WithHeader(name, value string) Option {
|
|
return func(cfg *config) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return errors.New("client: header name must not be empty")
|
|
}
|
|
if cfg.headers == nil {
|
|
cfg.headers = make(http.Header)
|
|
}
|
|
cfg.headers.Add(name, value)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithSessions gives the client a private cookie jar. Use one such Client per
|
|
// independent user session; do not share it globally in a web server.
|
|
func WithSessions() Option {
|
|
return func(cfg *config) error {
|
|
cfg.sessions = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithSessionCookieName selects the cookie copied by ForRequest. It should
|
|
// match api.Config.Session.CookieName.
|
|
func WithSessionCookieName(name string) Option {
|
|
return func(cfg *config) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return errors.New("client: session cookie name must not be empty")
|
|
}
|
|
cfg.cookieName = name
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithInitialCookies enables sessions and seeds the private cookie jar with
|
|
// the configured session cookie. Other frontend cookies are not forwarded.
|
|
func WithInitialCookies(cookies ...*http.Cookie) Option {
|
|
return func(cfg *config) error {
|
|
cfg.sessions = true
|
|
cfg.initialCookies = append(cfg.initialCookies, cookies...)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// New constructs a Gardomatic API client. baseURL may contain a path prefix;
|
|
// API paths are resolved below that prefix.
|
|
func New(baseURL string, options ...Option) (*Client, error) {
|
|
parsedURL, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: parse base URL: %w", err)
|
|
}
|
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
|
return nil, errors.New("client: base URL must use http or https")
|
|
}
|
|
if parsedURL.Host == "" {
|
|
return nil, errors.New("client: base URL must include a host")
|
|
}
|
|
if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
|
|
return nil, errors.New("client: base URL must not contain a query or fragment")
|
|
}
|
|
parsedURL.Path = strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
|
|
cfg := config{
|
|
httpClient: &http.Client{Timeout: defaultTimeout},
|
|
headers: make(http.Header),
|
|
cookieName: defaultSessionCookieName,
|
|
}
|
|
for _, option := range options {
|
|
if option == nil {
|
|
return nil, errors.New("client: option must not be nil")
|
|
}
|
|
if err := option(&cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
httpClient := *cfg.httpClient
|
|
if cfg.sessions {
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: create cookie jar: %w", err)
|
|
}
|
|
jar.SetCookies(parsedURL, sessionCookies(cfg.initialCookies, cfg.cookieName))
|
|
httpClient.Jar = jar
|
|
}
|
|
|
|
return &Client{
|
|
baseURL: parsedURL,
|
|
httpClient: &httpClient,
|
|
bearerToken: cfg.bearerToken,
|
|
headers: cfg.headers.Clone(),
|
|
cookieName: cfg.cookieName,
|
|
}, nil
|
|
}
|
|
|
|
func sessionCookies(cookies []*http.Cookie, name string) []*http.Cookie {
|
|
for _, cookie := range cookies {
|
|
if cookie != nil && cookie.Name == name {
|
|
return []*http.Cookie{cookie}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ForRequest returns a client with a private cookie jar seeded from r. This is
|
|
// the safe way to use a shared base client in an HTTP frontend.
|
|
func (c *Client) ForRequest(r *http.Request) (*Client, error) {
|
|
if r == nil {
|
|
return nil, errors.New("client: request must not be nil")
|
|
}
|
|
|
|
httpClient := *c.httpClient
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: create cookie jar: %w", err)
|
|
}
|
|
jar.SetCookies(c.baseURL, sessionCookies(r.Cookies(), c.cookieName))
|
|
httpClient.Jar = jar
|
|
|
|
return &Client{
|
|
baseURL: c.baseURL,
|
|
httpClient: &httpClient,
|
|
bearerToken: c.bearerToken,
|
|
headers: c.headers.Clone(),
|
|
cookieName: c.cookieName,
|
|
}, nil
|
|
}
|
|
|
|
// Response contains the HTTP response metadata. The body has already been
|
|
// read and closed. Cookies remains useful for forwarding Set-Cookie headers.
|
|
type Response struct {
|
|
*http.Response
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, input, output any) (*Response, error) {
|
|
if ctx == nil {
|
|
return nil, errors.New("client: context must not be nil")
|
|
}
|
|
|
|
var body io.Reader
|
|
if input != nil {
|
|
encoded, err := json.Marshal(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: encode request: %w", err)
|
|
}
|
|
body = bytes.NewReader(encoded)
|
|
}
|
|
|
|
relativeURL, err := url.Parse(strings.TrimPrefix(path, "/"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: parse request path: %w", err)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(relativeURL).String(), body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: create request: %w", err)
|
|
}
|
|
request.Header = c.headers.Clone()
|
|
request.Header.Set("Accept", "application/json")
|
|
if input != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if c.bearerToken != "" {
|
|
request.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
|
}
|
|
|
|
httpResponse, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client: execute request: %w", err)
|
|
}
|
|
response := &Response{httpResponse}
|
|
defer httpResponse.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(io.LimitReader(httpResponse.Body, maxResponseSize+1))
|
|
if err != nil {
|
|
return response, fmt.Errorf("client: read response: %w", err)
|
|
}
|
|
if len(responseBody) > maxResponseSize {
|
|
return response, errors.New("client: response exceeds 2 MiB")
|
|
}
|
|
|
|
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
|
|
return response, newAPIError(httpResponse, responseBody)
|
|
}
|
|
if output == nil || httpResponse.StatusCode == http.StatusNoContent || len(bytes.TrimSpace(responseBody)) == 0 {
|
|
return response, nil
|
|
}
|
|
if err := json.Unmarshal(responseBody, output); err != nil {
|
|
return response, fmt.Errorf("client: decode response: %w", err)
|
|
}
|
|
|
|
return response, nil
|
|
}
|