client.go raw
1 // Package nodion contains a client of the DNS API of Nodion.
2 package nodion
3
4 import (
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "net/url"
13 "time"
14
15 querystring "github.com/google/go-querystring/query"
16 )
17
18 const defaultBaseURL = "https://api.nodion.com/v1/"
19
20 // Client the Nodion API client.
21 type Client struct {
22 HTTPClient *http.Client
23 baseURL *url.URL
24 apiToken string
25 }
26
27 // NewClient creates a new Client.
28 func NewClient(apiToken string) (*Client, error) {
29 baseURL, err := url.Parse(defaultBaseURL)
30 if err != nil {
31 return nil, err
32 }
33
34 if apiToken == "" {
35 return nil, errors.New("API token is required")
36 }
37
38 return &Client{
39 HTTPClient: &http.Client{Timeout: 5 * time.Second},
40 baseURL: baseURL,
41 apiToken: apiToken,
42 }, nil
43 }
44
45 // CreateZone To create a new DNS Zone.
46 // https://www.nodion.com/en/docs/dns/api/#post-dns-zone
47 func (c Client) CreateZone(ctx context.Context, name string) (*Zone, error) {
48 endpoint := c.baseURL.JoinPath("dns_zones")
49
50 body := &bytes.Buffer{}
51 err := json.NewEncoder(body).Encode(Zone{Name: name})
52 if err != nil {
53 return nil, fmt.Errorf("encode request body: %w", err)
54 }
55
56 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), body)
57 if err != nil {
58 return nil, fmt.Errorf("create request: %w", err)
59 }
60
61 var result ZoneResponse
62 err = c.do(req, &result)
63 if err != nil {
64 return nil, err
65 }
66
67 return &result.Zone, nil
68 }
69
70 // DeleteZone To delete an existing DNS Zone.
71 // https://www.nodion.com/en/docs/dns/api/#delete-dns-zone
72 func (c Client) DeleteZone(ctx context.Context, zoneID string) (bool, error) {
73 endpoint := c.baseURL.JoinPath("dns_zones", zoneID)
74
75 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint.String(), http.NoBody)
76 if err != nil {
77 return false, fmt.Errorf("create request: %w", err)
78 }
79
80 var result DeleteResponse
81 err = c.do(req, &result)
82 if err != nil {
83 return false, err
84 }
85
86 return result.Deleted, nil
87 }
88
89 // GetZones To list all existing DNS zones.
90 // https://www.nodion.com/en/docs/dns/api/#get-dns-zones
91 func (c Client) GetZones(ctx context.Context, filter *ZonesFilter) ([]Zone, error) {
92 endpoint := c.baseURL.JoinPath("dns_zones")
93
94 values, err := querystring.Values(filter)
95 if err != nil {
96 return nil, fmt.Errorf("create zones filter: %w", err)
97 }
98
99 endpoint.RawQuery = values.Encode()
100
101 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
102 if err != nil {
103 return nil, fmt.Errorf("create request: %w", err)
104 }
105
106 var result ZonesResponse
107 err = c.do(req, &result)
108 if err != nil {
109 return nil, err
110 }
111
112 return result.Zones, nil
113 }
114
115 // CreateRecord To create a new Record for a DNS zone.
116 // https://www.nodion.com/en/docs/dns/api/#post-dns-record
117 func (c Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) {
118 endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "records")
119
120 body := &bytes.Buffer{}
121 err := json.NewEncoder(body).Encode(record)
122 if err != nil {
123 return nil, fmt.Errorf("encode request body: %w", err)
124 }
125
126 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), body)
127 if err != nil {
128 return nil, fmt.Errorf("create request: %w", err)
129 }
130
131 var result RecordResponse
132 err = c.do(req, &result)
133 if err != nil {
134 return nil, err
135 }
136
137 return &result.Record, nil
138 }
139
140 // DeleteRecord To delete an existing Record for a DNS zone.
141 // https://www.nodion.com/en/docs/dns/api/#delete-dns-record
142 func (c Client) DeleteRecord(ctx context.Context, zoneID, recordID string) (bool, error) {
143 endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "records", recordID)
144
145 req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint.String(), http.NoBody)
146 if err != nil {
147 return false, fmt.Errorf("create request: %w", err)
148 }
149
150 var result DeleteResponse
151 err = c.do(req, &result)
152 if err != nil {
153 return false, err
154 }
155
156 return result.Deleted, nil
157 }
158
159 // GetRecords To list all existing Records of a DNS zone.
160 // https://www.nodion.com/en/docs/dns/api/#get-dns-records
161 func (c Client) GetRecords(ctx context.Context, zoneID string, filter *RecordsFilter) ([]Record, error) {
162 endpoint := c.baseURL.JoinPath("dns_zones", zoneID, "records")
163
164 values, err := querystring.Values(filter)
165 if err != nil {
166 return nil, fmt.Errorf("create records filter: %w", err)
167 }
168
169 endpoint.RawQuery = values.Encode()
170
171 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
172 if err != nil {
173 return nil, fmt.Errorf("create request: %w", err)
174 }
175
176 var result RecordsResponse
177 err = c.do(req, &result)
178 if err != nil {
179 return nil, err
180 }
181
182 return result.Records, nil
183 }
184
185 func (c Client) do(req *http.Request, result any) error {
186 req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiToken))
187
188 req.Header.Set("Content-Type", "application/json")
189 req.Header.Set("Accept", "application/json")
190
191 resp, err := c.HTTPClient.Do(req)
192 if err != nil {
193 return fmt.Errorf("API error: %w", err)
194 }
195
196 defer func() { _ = resp.Body.Close() }()
197
198 if resp.StatusCode/100 != 2 {
199 return readError(req.URL, resp)
200 }
201
202 raw, err := io.ReadAll(resp.Body)
203 if err != nil {
204 return fmt.Errorf("read response body: %w", err)
205 }
206
207 err = json.Unmarshal(raw, result)
208 if err != nil {
209 return fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", result, resp.StatusCode, err, string(raw))
210 }
211
212 return nil
213 }
214
215 func readError(endpoint *url.URL, resp *http.Response) error {
216 content, err := io.ReadAll(resp.Body)
217 if err != nil {
218 return errors.New(toUnreadableBodyMessage(endpoint, content))
219 }
220
221 errAPI := &APIError{StatusCode: resp.StatusCode}
222
223 if len(content) == 0 {
224 errAPI.Errors = []string{http.StatusText(resp.StatusCode)}
225 return errAPI
226 }
227
228 err = json.Unmarshal(content, errAPI)
229 if err != nil {
230 errAPI.Errors = []string{toUnreadableBodyMessage(endpoint, content)}
231 return fmt.Errorf("unmarshaling error: %w", errAPI)
232 }
233
234 return errAPI
235 }
236
237 func toUnreadableBodyMessage(endpoint *url.URL, rawBody []byte) string {
238 return fmt.Sprintf("the request %s received a response with a body which is an invalid format or not readable: %q", endpoint, string(rawBody))
239 }
240