client.go raw

   1  package internal
   2  
   3  import (
   4  	"bytes"
   5  	"context"
   6  	"encoding/json"
   7  	"fmt"
   8  	"io"
   9  	"net/http"
  10  	"net/url"
  11  	"strings"
  12  	"time"
  13  
  14  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  15  )
  16  
  17  // defaultBaseURL represents the API endpoint to call.
  18  const defaultBaseURL = "https://napi.arvancloud.ir"
  19  
  20  const authorizationHeader = "Authorization"
  21  
  22  // Client the ArvanCloud client.
  23  type Client struct {
  24  	apiKey string
  25  
  26  	baseURL    *url.URL
  27  	HTTPClient *http.Client
  28  }
  29  
  30  // NewClient Creates a new Client.
  31  func NewClient(apiKey string) *Client {
  32  	baseURL, _ := url.Parse(defaultBaseURL)
  33  
  34  	return &Client{
  35  		apiKey:     apiKey,
  36  		baseURL:    baseURL,
  37  		HTTPClient: &http.Client{Timeout: 5 * time.Second},
  38  	}
  39  }
  40  
  41  // GetTxtRecord gets a TXT record.
  42  func (c *Client) GetTxtRecord(ctx context.Context, domain, name, value string) (*DNSRecord, error) {
  43  	records, err := c.getRecords(ctx, domain, name)
  44  	if err != nil {
  45  		return nil, err
  46  	}
  47  
  48  	for _, record := range records {
  49  		if equalsTXTRecord(record, name, value) {
  50  			return &record, nil
  51  		}
  52  	}
  53  
  54  	return nil, fmt.Errorf("could not find record: Domain: %s; Record: %s", domain, name)
  55  }
  56  
  57  // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.list
  58  func (c *Client) getRecords(ctx context.Context, domain, search string) ([]DNSRecord, error) {
  59  	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records")
  60  
  61  	if search != "" {
  62  		query := endpoint.Query()
  63  		query.Set("search", strings.ReplaceAll(search, "_", ""))
  64  		endpoint.RawQuery = query.Encode()
  65  	}
  66  
  67  	req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
  68  	if err != nil {
  69  		return nil, err
  70  	}
  71  
  72  	response := &apiResponse[[]DNSRecord]{}
  73  
  74  	err = c.do(req, http.StatusOK, response)
  75  	if err != nil {
  76  		return nil, fmt.Errorf("could not get records %s: Domain: %s: %w", search, domain, err)
  77  	}
  78  
  79  	return response.Data, nil
  80  }
  81  
  82  // CreateRecord creates a DNS record.
  83  // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.create
  84  func (c *Client) CreateRecord(ctx context.Context, domain string, record DNSRecord) (*DNSRecord, error) {
  85  	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records")
  86  
  87  	req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
  88  	if err != nil {
  89  		return nil, err
  90  	}
  91  
  92  	response := &apiResponse[*DNSRecord]{}
  93  
  94  	err = c.do(req, http.StatusCreated, response)
  95  	if err != nil {
  96  		return nil, fmt.Errorf("could not create record; Domain: %s: %w", domain, err)
  97  	}
  98  
  99  	return response.Data, nil
 100  }
 101  
 102  // DeleteRecord deletes a DNS record.
 103  // https://www.arvancloud.ir/docs/api/cdn/4.0#operation/dns_records.remove
 104  func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
 105  	endpoint := c.baseURL.JoinPath("cdn", "4.0", "domains", domain, "dns-records", id)
 106  
 107  	req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
 108  	if err != nil {
 109  		return err
 110  	}
 111  
 112  	err = c.do(req, http.StatusOK, nil)
 113  	if err != nil {
 114  		return fmt.Errorf("could not delete record %s; Domain: %s: %w", id, domain, err)
 115  	}
 116  
 117  	return nil
 118  }
 119  
 120  func (c *Client) do(req *http.Request, expectedStatus int, result any) error {
 121  	req.Header.Set(authorizationHeader, c.apiKey)
 122  
 123  	resp, err := c.HTTPClient.Do(req)
 124  	if err != nil {
 125  		return errutils.NewHTTPDoError(req, err)
 126  	}
 127  
 128  	defer func() { _ = resp.Body.Close() }()
 129  
 130  	if resp.StatusCode != expectedStatus {
 131  		return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 132  	}
 133  
 134  	if result == nil {
 135  		return nil
 136  	}
 137  
 138  	raw, err := io.ReadAll(resp.Body)
 139  	if err != nil {
 140  		return errutils.NewReadResponseError(req, resp.StatusCode, err)
 141  	}
 142  
 143  	err = json.Unmarshal(raw, result)
 144  	if err != nil {
 145  		return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
 146  	}
 147  
 148  	return nil
 149  }
 150  
 151  func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
 152  	buf := new(bytes.Buffer)
 153  
 154  	if payload != nil {
 155  		err := json.NewEncoder(buf).Encode(payload)
 156  		if err != nil {
 157  			return nil, fmt.Errorf("failed to create request JSON body: %w", err)
 158  		}
 159  	}
 160  
 161  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
 162  	if err != nil {
 163  		return nil, fmt.Errorf("unable to create request: %w", err)
 164  	}
 165  
 166  	req.Header.Set("Accept", "application/json")
 167  
 168  	if payload != nil {
 169  		req.Header.Set("Content-Type", "application/json")
 170  	}
 171  
 172  	return req, nil
 173  }
 174  
 175  func equalsTXTRecord(record DNSRecord, name, value string) bool {
 176  	if record.Type != "txt" {
 177  		return false
 178  	}
 179  
 180  	if record.Name != name {
 181  		return false
 182  	}
 183  
 184  	data, ok := record.Value.(map[string]any)
 185  	if !ok {
 186  		return false
 187  	}
 188  
 189  	return data["text"] == value
 190  }
 191