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 }