desec.go raw

   1  package desec
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"encoding/json"
   7  	"fmt"
   8  	"io"
   9  	"net/http"
  10  	"net/url"
  11  	"time"
  12  
  13  	"github.com/hashicorp/go-retryablehttp"
  14  )
  15  
  16  const defaultBaseURL = "https://desec.io/api/v1/"
  17  
  18  type httpDoer interface {
  19  	Do(req *http.Request) (*http.Response, error)
  20  }
  21  
  22  type service struct {
  23  	client *Client
  24  }
  25  
  26  // ClientOptions the options of the Client.
  27  type ClientOptions struct {
  28  	// HTTPClient HTTP client used to communicate with the API.
  29  	HTTPClient *http.Client
  30  
  31  	// Maximum number of retries
  32  	RetryMax int
  33  
  34  	// Customer logger instance. Can be either Logger or LeveledLogger
  35  	Logger any
  36  }
  37  
  38  // NewDefaultClientOptions creates a new ClientOptions with default values.
  39  func NewDefaultClientOptions() ClientOptions {
  40  	return ClientOptions{
  41  		HTTPClient: &http.Client{Timeout: 10 * time.Second},
  42  		RetryMax:   5,
  43  		Logger:     nil,
  44  	}
  45  }
  46  
  47  // Client deSEC API client.
  48  type Client struct {
  49  	// Base URL for API requests.
  50  	BaseURL string
  51  
  52  	httpClient httpDoer
  53  
  54  	token string
  55  
  56  	common service // Reuse a single struct instead of allocating one for each service on the heap.
  57  
  58  	// Services used for talking to different parts of the deSEC API.
  59  	Account       *AccountService
  60  	Tokens        *TokensService
  61  	TokenPolicies *TokenPoliciesService
  62  	Records       *RecordsService
  63  	Domains       *DomainsService
  64  }
  65  
  66  // New creates a new Client.
  67  func New(token string, opts ClientOptions) *Client {
  68  	// https://github.com/desec-io/desec-stack/blob/main/docs/rate-limits.rst
  69  	retryClient := retryablehttp.NewClient()
  70  	retryClient.RetryMax = opts.RetryMax
  71  	retryClient.HTTPClient = opts.HTTPClient
  72  	retryClient.Logger = opts.Logger
  73  
  74  	client := &Client{
  75  		httpClient: retryClient.StandardClient(),
  76  		BaseURL:    defaultBaseURL,
  77  		token:      token,
  78  	}
  79  
  80  	client.common.client = client
  81  
  82  	client.Account = (*AccountService)(&client.common)
  83  	client.Tokens = (*TokensService)(&client.common)
  84  	client.TokenPolicies = (*TokenPoliciesService)(&client.common)
  85  	client.Records = (*RecordsService)(&client.common)
  86  	client.Domains = (*DomainsService)(&client.common)
  87  
  88  	return client
  89  }
  90  
  91  func (c *Client) newRequest(ctx context.Context, method string, endpoint fmt.Stringer, reqBody any) (*http.Request, error) {
  92  	buf := new(bytes.Buffer)
  93  
  94  	if reqBody != nil {
  95  		err := json.NewEncoder(buf).Encode(reqBody)
  96  		if err != nil {
  97  			return nil, fmt.Errorf("failed to marshal request body: %w", err)
  98  		}
  99  	}
 100  
 101  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 102  	if err != nil {
 103  		return nil, fmt.Errorf("failed to create request: %w", err)
 104  	}
 105  
 106  	req.Header.Set("Content-Type", "application/json")
 107  
 108  	if c.token != "" {
 109  		req.Header.Set("Authorization", fmt.Sprintf("Token %s", c.token))
 110  	}
 111  
 112  	return req, nil
 113  }
 114  
 115  func (c *Client) createEndpoint(parts ...string) (*url.URL, error) {
 116  	base, err := url.Parse(c.BaseURL)
 117  	if err != nil {
 118  		return nil, err
 119  	}
 120  
 121  	endpoint := base.JoinPath(parts...)
 122  	endpoint.Path += "/"
 123  
 124  	return endpoint, nil
 125  }
 126  
 127  func handleResponse(resp *http.Response, respData any) error {
 128  	body, err := io.ReadAll(resp.Body)
 129  	if err != nil {
 130  		return &APIError{
 131  			StatusCode: resp.StatusCode,
 132  			err:        fmt.Errorf("failed to read response body: %w", err),
 133  		}
 134  	}
 135  
 136  	if len(body) == 0 {
 137  		return nil
 138  	}
 139  
 140  	err = json.Unmarshal(body, respData)
 141  	if err != nil {
 142  		return fmt.Errorf("failed to umarshal response body: %w", err)
 143  	}
 144  
 145  	return nil
 146  }
 147  
 148  func handleError(resp *http.Response) error {
 149  	switch resp.StatusCode {
 150  	case http.StatusNotFound:
 151  		return readError(resp, &NotFoundError{})
 152  	default:
 153  		return readRawError(resp)
 154  	}
 155  }
 156  
 157  // Pointer creates pointer of string.
 158  func Pointer[T string](v T) *T { return &v }
 159