@@ -0,0 +1,262 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user