This repository has been archived on 2026-08-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
terraform-provider-dynu/internal/dynuclient/client.go
T

449 lines
12 KiB
Go

package dynuclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const defaultBaseURL = "https://api.dynu.com/v2"
type Client struct {
apiKey string
baseURL string
httpClient *http.Client
}
type Option func(*Client)
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
c.httpClient = httpClient
}
}
func WithBaseURL(baseURL string) Option {
return func(c *Client) {
c.baseURL = strings.TrimRight(baseURL, "/")
}
}
func New(apiKey string, opts ...Option) *Client {
c := &Client{
apiKey: apiKey,
baseURL: defaultBaseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
for _, opt := range opts {
opt(c)
}
return c
}
type apiException struct {
StatusCode int `json:"statusCode"`
Type string `json:"type"`
Message string `json:"message"`
}
type APIError struct {
StatusCode int
Type string
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("dynu API error %d (%s): %s", e.StatusCode, e.Type, e.Message)
}
type apiResponse struct {
StatusCode int `json:"statusCode"`
Exception *apiException `json:"exception"`
}
type Domain struct {
ID int64 `json:"id"`
Name string `json:"name"`
UnicodeName string `json:"unicodeName"`
Token string `json:"token"`
State string `json:"state"`
Group string `json:"group"`
IPv4Address string `json:"ipv4Address"`
IPv6Address string `json:"ipv6Address"`
TTL int64 `json:"ttl"`
IPv4 bool `json:"ipv4"`
IPv6 bool `json:"ipv6"`
IPv4WildcardAlias bool `json:"ipv4WildcardAlias"`
IPv6WildcardAlias bool `json:"ipv6WildcardAlias"`
AllowZoneTransfer bool `json:"allowZoneTransfer"`
DNSSEC bool `json:"dnssec"`
CreatedOn string `json:"createdOn"`
UpdatedOn string `json:"updatedOn"`
}
type DNSRecord struct {
ID int64 `json:"id"`
DomainID int64 `json:"domainId"`
DomainName string `json:"domainName"`
NodeName string `json:"nodeName"`
Hostname string `json:"hostname"`
RecordType string `json:"recordType"`
State bool `json:"state"`
TTL int64 `json:"ttl"`
Content string `json:"content"`
UpdatedOn string `json:"updatedOn"`
Group string `json:"group"`
Host string `json:"host"`
Priority int64 `json:"priority"`
Weight int64 `json:"weight"`
Port int64 `json:"port"`
Flags int64 `json:"flags"`
Tag string `json:"tag"`
Value string `json:"value"`
}
type CreateDNSRecordRequest struct {
NodeName string `json:"nodeName,omitempty"`
RecordType string `json:"recordType"`
Content *string `json:"content,omitempty"`
TTL int64 `json:"ttl,omitempty"`
State *bool `json:"state,omitempty"`
Group string `json:"group,omitempty"`
Host string `json:"host,omitempty"`
Priority int64 `json:"priority,omitempty"`
Weight int64 `json:"weight,omitempty"`
Port int64 `json:"port,omitempty"`
Flags int64 `json:"flags,omitempty"`
Tag string `json:"tag,omitempty"`
Value string `json:"value,omitempty"`
}
type UpdateDNSRecordRequest struct {
NodeName string `json:"nodeName,omitempty"`
RecordType string `json:"recordType"`
Content *string `json:"content,omitempty"`
TTL int64 `json:"ttl,omitempty"`
State *bool `json:"state,omitempty"`
Group string `json:"group,omitempty"`
Host string `json:"host,omitempty"`
Priority int64 `json:"priority,omitempty"`
Weight int64 `json:"weight,omitempty"`
Port int64 `json:"port,omitempty"`
Flags int64 `json:"flags,omitempty"`
Tag string `json:"tag,omitempty"`
Value string `json:"value,omitempty"`
}
type CreateDomainRequest struct {
Name string `json:"name"`
IPv4Address string `json:"ipv4Address,omitempty"`
IPv6Address string `json:"ipv6Address,omitempty"`
TTL int64 `json:"ttl,omitempty"`
Group string `json:"group,omitempty"`
}
type UpdateDomainRequest = CreateDomainRequest
type listDomainsResponse struct {
apiResponse
Domains []Domain `json:"domains"`
}
type getDomainResponse struct {
apiResponse
Domain
}
type listDNSRecordsResponse struct {
apiResponse
DNSRecords []DNSRecord `json:"dnsRecords"`
}
type getDNSRecordResponse struct {
apiResponse
DNSRecord
}
type getRootResponse struct {
apiResponse
ID int64 `json:"id"`
Hostname string `json:"hostname"`
DomainName string `json:"domainName"`
Node string `json:"node"`
}
func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
var resp listDomainsResponse
if err := c.doRequest(ctx, http.MethodGet, "/dns", nil, &resp); err != nil {
return nil, err
}
return resp.Domains, nil
}
func (c *Client) GetDomainByID(ctx context.Context, domainID int64) (*Domain, error) {
var resp getDomainResponse
if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d", domainID), nil, &resp); err != nil {
return nil, err
}
return &resp.Domain, nil
}
func (c *Client) CreateDomain(ctx context.Context, req CreateDomainRequest) (*Domain, error) {
var resp getDomainResponse
if err := c.doRequest(ctx, http.MethodPost, "/dns", req, &resp); err != nil {
return nil, err
}
return &resp.Domain, nil
}
func (c *Client) UpdateDomain(ctx context.Context, domainID int64, req UpdateDomainRequest) (*Domain, error) {
var resp getDomainResponse
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d", domainID), req, &resp); err != nil {
return nil, err
}
return &resp.Domain, nil
}
func (c *Client) DeleteDomain(ctx context.Context, domainID int64) error {
return c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/dns/%d", domainID), nil, nil)
}
func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, string, error) {
var resp getRootResponse
if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/getroot/%s", url.PathEscape(hostname)), nil, &resp); err != nil {
return 0, "", err
}
if resp.ID == 0 || resp.DomainName == "" {
return 0, "", errors.New("dynu API returned an incomplete root domain response")
}
return resp.ID, resp.DomainName, nil
}
func (c *Client) ListDNSRecords(ctx context.Context, domainID int64) ([]DNSRecord, error) {
var resp listDNSRecordsResponse
if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d/record", domainID), nil, &resp); err != nil {
return nil, err
}
for i := range resp.DNSRecords {
normalizeDNSRecord(&resp.DNSRecords[i])
}
return resp.DNSRecords, nil
}
func (c *Client) GetDNSRecord(ctx context.Context, domainID int64, recordID int64) (*DNSRecord, error) {
var resp getDNSRecordResponse
if err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), nil, &resp); err != nil {
return nil, err
}
normalizeDNSRecord(&resp.DNSRecord)
return &resp.DNSRecord, nil
}
func (c *Client) CreateDNSRecord(ctx context.Context, domainID int64, req CreateDNSRecordRequest) (*DNSRecord, error) {
var resp getDNSRecordResponse
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record", domainID), buildDNSRecordUpsertPayload(req), &resp); err != nil {
return nil, err
}
normalizeDNSRecord(&resp.DNSRecord)
return &resp.DNSRecord, nil
}
func (c *Client) UpdateDNSRecord(ctx context.Context, domainID int64, recordID int64, req UpdateDNSRecordRequest) (*DNSRecord, error) {
var resp getDNSRecordResponse
if err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), buildDNSRecordUpsertPayload(CreateDNSRecordRequest(req)), &resp); err != nil {
return nil, err
}
normalizeDNSRecord(&resp.DNSRecord)
return &resp.DNSRecord, nil
}
func (c *Client) DeleteDNSRecord(ctx context.Context, domainID int64, recordID int64) error {
return c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/dns/%d/record/%d", domainID, recordID), nil, nil)
}
func (c *Client) doRequest(ctx context.Context, method string, path string, requestBody any, target any) error {
requestURL := c.baseURL + path
var bodyReader io.Reader
if requestBody != nil {
body, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("failed to encode dynu API request: %w", err)
}
bodyReader = bytes.NewBuffer(body)
}
req, err := http.NewRequestWithContext(ctx, method, requestURL, bodyReader)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("API-Key", c.apiKey)
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
payload, err := io.ReadAll(res.Body)
if err != nil {
return err
}
apiErr := parseAPIException(payload)
if res.StatusCode < 200 || res.StatusCode >= 300 {
if apiErr != nil {
return apiErr
}
return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload)))
}
if len(bytes.TrimSpace(payload)) > 0 && target != nil {
if err := json.Unmarshal(payload, target); err != nil {
return fmt.Errorf("failed to decode dynu API response: %w", err)
}
}
if apiErr != nil {
return apiErr
}
return nil
}
func parseAPIException(payload []byte) error {
apiResult := apiResponse{}
if err := json.Unmarshal(payload, &apiResult); err != nil {
return nil
}
if apiResult.Exception != nil {
return &APIError{
StatusCode: apiResult.Exception.StatusCode,
Type: apiResult.Exception.Type,
Message: apiResult.Exception.Message,
}
}
// Some Dynu responses surface API failures at the top level instead of under exception.
topLevel := apiException{}
if err := json.Unmarshal(payload, &topLevel); err != nil || topLevel.StatusCode == 0 || topLevel.Type == "" {
return nil
}
return &APIError{
StatusCode: topLevel.StatusCode,
Type: topLevel.Type,
Message: topLevel.Message,
}
}
type dnsRecordUpsertPayload struct {
NodeName string `json:"nodeName,omitempty"`
RecordType string `json:"recordType"`
Content *string `json:"content,omitempty"`
IPv4Address string `json:"ipv4Address,omitempty"`
IPv6Address string `json:"ipv6Address,omitempty"`
TTL int64 `json:"ttl,omitempty"`
State *bool `json:"state,omitempty"`
Group string `json:"group,omitempty"`
Host string `json:"host,omitempty"`
Priority int64 `json:"priority,omitempty"`
Weight int64 `json:"weight,omitempty"`
Port int64 `json:"port,omitempty"`
Flags int64 `json:"flags,omitempty"`
Tag string `json:"tag,omitempty"`
Value string `json:"value,omitempty"`
}
func buildDNSRecordUpsertPayload(req CreateDNSRecordRequest) dnsRecordUpsertPayload {
normalizedType := strings.ToUpper(strings.TrimSpace(req.RecordType))
normalizedContent := normalizeOptionalContent(req.Content)
payload := dnsRecordUpsertPayload{
NodeName: req.NodeName,
RecordType: normalizedType,
TTL: req.TTL,
State: req.State,
Group: req.Group,
Host: req.Host,
Priority: req.Priority,
Weight: req.Weight,
Port: req.Port,
Flags: req.Flags,
Tag: req.Tag,
Value: req.Value,
}
switch normalizedType {
case "A":
if normalizedContent != nil {
payload.IPv4Address = *normalizedContent
}
case "AAAA":
if normalizedContent != nil {
payload.IPv6Address = *normalizedContent
}
case "CNAME":
if normalizedContent != nil {
payload.Host = *normalizedContent
}
case "MX", "SRV", "NS", "PTR":
if normalizedContent != nil {
payload.Host = *normalizedContent
}
case "CAA":
if normalizedContent != nil && payload.Value == "" {
payload.Value = *normalizedContent
}
default:
payload.Content = normalizedContent
}
return payload
}
func normalizeOptionalContent(content *string) *string {
if content == nil {
return nil
}
trimmed := strings.TrimSpace(*content)
if trimmed == "" {
return nil
}
return &trimmed
}
var zoneStyleContentPattern = regexp.MustCompile(`(?i)^\S+\.\s+\d+\s+IN\s+\S+\s+(.+)$`)
func normalizeDNSRecord(record *DNSRecord) {
if record == nil {
return
}
matches := zoneStyleContentPattern.FindStringSubmatch(strings.TrimSpace(record.Content))
if len(matches) != 2 {
return
}
record.Content = strings.TrimSpace(matches[1])
}