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