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