Implement read-only Dynu Terraform provider skeleton

This commit is contained in:
beatz174-bit
2026-04-21 13:02:42 +10:00
parent 0889d83a0a
commit b268d7a6aa
16 changed files with 1041 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
package dynuclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"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 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"`
}
type listDomainsResponse struct {
apiResponse
Domains []Domain `json:"domains"`
}
type getDomainResponse struct {
apiResponse
Domain
}
type listDNSRecordsResponse struct {
apiResponse
DNSRecords []DNSRecord `json:"dnsRecords"`
}
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.doGET(ctx, "/dns", &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.doGET(ctx, fmt.Sprintf("/dns/%d", domainID), &resp); err != nil {
return nil, err
}
return &resp.Domain, nil
}
func (c *Client) GetRootDomain(ctx context.Context, hostname string) (int64, string, error) {
var resp getRootResponse
if err := c.doGET(ctx, fmt.Sprintf("/dns/getroot/%s", hostname), &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.doGET(ctx, fmt.Sprintf("/dns/%d/record", domainID), &resp); err != nil {
return nil, err
}
return resp.DNSRecords, nil
}
func (c *Client) doGET(ctx context.Context, path string, target any) error {
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("API-Key", c.apiKey)
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
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("dynu API returned status %d: %s", res.StatusCode, strings.TrimSpace(string(payload)))
}
if err := json.Unmarshal(payload, target); err != nil {
return fmt.Errorf("failed to decode dynu API response: %w", err)
}
apiResult := apiResponse{}
if err := json.Unmarshal(payload, &apiResult); err == nil && apiResult.Exception != nil {
return fmt.Errorf("dynu API error %d (%s): %s", apiResult.Exception.StatusCode, apiResult.Exception.Type, apiResult.Exception.Message)
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package dynuclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestListDomains(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/dns" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("API-Key"); got != "test-key" {
t.Fatalf("unexpected api key header: %s", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"statusCode":200,"domains":[{"id":1,"name":"example.com","state":"Complete"}]}`))
}))
defer ts.Close()
client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client()))
domains, err := client.ListDomains(context.Background())
if err != nil {
t.Fatalf("ListDomains() error = %v", err)
}
if len(domains) != 1 {
t.Fatalf("expected 1 domain, got %d", len(domains))
}
if domains[0].Name != "example.com" {
t.Fatalf("unexpected domain name %q", domains[0].Name)
}
}
func TestDoGetErrorResponse(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"statusCode":401,"exception":{"statusCode":401,"type":"Authentication Exception","message":"invalid"}}`))
}))
defer ts.Close()
client := New("test-key", WithBaseURL(ts.URL), WithHTTPClient(ts.Client()))
_, err := client.ListDomains(context.Background())
if err == nil {
t.Fatal("expected error, got nil")
}
}