Files
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

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
}