aboutsummaryrefslogtreecommitdiff
path: root/internal/porkbun/client.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/porkbun/client.go')
-rw-r--r--internal/porkbun/client.go248
1 files changed, 248 insertions, 0 deletions
diff --git a/internal/porkbun/client.go b/internal/porkbun/client.go
new file mode 100644
index 0000000..93c54eb
--- /dev/null
+++ b/internal/porkbun/client.go
@@ -0,0 +1,248 @@
+package porkbun
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ neturl "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const DefaultBaseURL = "https://api.porkbun.com/api/json/v3"
+
+type Client struct {
+ apiKey string
+ secretAPIKey string
+ baseURL string
+ httpClient *http.Client
+}
+
+type Option func(*Client)
+
+func WithBaseURL(u string) Option {
+ return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") }
+}
+
+func WithHTTPClient(h *http.Client) Option {
+ return func(c *Client) { c.httpClient = h }
+}
+
+func NewClient(apiKey, secretAPIKey string, opts ...Option) *Client {
+ c := &Client{
+ apiKey: apiKey,
+ secretAPIKey: secretAPIKey,
+ baseURL: DefaultBaseURL,
+ httpClient: &http.Client{Timeout: 30 * time.Second},
+ }
+ for _, opt := range opts {
+ opt(c)
+ }
+ return c
+}
+
+type APIError struct {
+ HTTPStatus int
+ Status string
+ Message string
+}
+
+func (e *APIError) Error() string {
+ msg := e.Message
+ if msg == "" {
+ msg = "no error message returned"
+ }
+ return fmt.Sprintf("porkbun API error (HTTP %d, status %q): %s", e.HTTPStatus, e.Status, msg)
+}
+
+type Record struct {
+ ID string
+ Name string
+ Type string
+ Content string
+ TTL int
+ Prio int
+}
+
+type RecordSpec struct {
+ Name string
+ Type string
+ Content string
+ TTL int
+ Prio int
+}
+
+func (c *Client) Ping(ctx context.Context) (string, error) {
+ var out struct {
+ YourIP string `json:"yourIp"`
+ }
+ if err := c.post(ctx, "/ping", nil, &out); err != nil {
+ return "", err
+ }
+ return out.YourIP, nil
+}
+
+func (c *Client) CreateRecord(ctx context.Context, domain string, spec RecordSpec) (string, error) {
+ body := map[string]any{
+ "name": spec.Name,
+ "type": spec.Type,
+ "content": spec.Content,
+ // The API documents ttl and prio as strings.
+ "ttl": strconv.Itoa(spec.TTL),
+ "prio": strconv.Itoa(spec.Prio),
+ }
+ var out struct {
+ ID flexString `json:"id"`
+ }
+ if err := c.post(ctx, "/dns/create/"+url(domain), body, &out); err != nil {
+ return "", err
+ }
+ if out.ID == "" {
+ return "", fmt.Errorf("porkbun API reported success creating a record on %q but returned no record ID", domain)
+ }
+ return string(out.ID), nil
+}
+
+func (c *Client) EditRecord(ctx context.Context, domain, id string, spec RecordSpec) error {
+ body := map[string]any{
+ "type": spec.Type,
+ "content": spec.Content,
+ "ttl": strconv.Itoa(spec.TTL),
+ "prio": strconv.Itoa(spec.Prio),
+ }
+ return c.post(ctx, "/dns/edit/"+url(domain)+"/"+url(id), body, nil)
+}
+
+func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error {
+ return c.post(ctx, "/dns/delete/"+url(domain)+"/"+url(id), nil, nil)
+}
+
+func (c *Client) GetRecord(ctx context.Context, domain, id string) (*Record, error) {
+ var out struct {
+ Records []wireRecord `json:"records"`
+ }
+ if err := c.post(ctx, "/dns/retrieve/"+url(domain)+"/"+url(id), nil, &out); err != nil {
+ return nil, err
+ }
+ if len(out.Records) == 0 {
+ return nil, nil
+ }
+ rec := out.Records[0].toRecord()
+ return &rec, nil
+}
+
+type wireRecord struct {
+ ID flexString `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Content string `json:"content"`
+ TTL flexInt `json:"ttl"`
+ Prio flexInt `json:"prio"`
+}
+
+func (w wireRecord) toRecord() Record {
+ return Record{
+ ID: string(w.ID),
+ Name: w.Name,
+ Type: w.Type,
+ Content: w.Content,
+ TTL: int(w.TTL),
+ Prio: int(w.Prio),
+ }
+}
+
+type flexString string
+
+func (f *flexString) UnmarshalJSON(b []byte) error {
+ var s string
+ if err := json.Unmarshal(b, &s); err == nil {
+ *f = flexString(s)
+ return nil
+ }
+ var n json.Number
+ if err := json.Unmarshal(b, &n); err == nil {
+ *f = flexString(n.String())
+ return nil
+ }
+ return fmt.Errorf("expected string or number, got %s", b)
+}
+
+type flexInt int
+
+func (f *flexInt) UnmarshalJSON(b []byte) error {
+ if string(b) == "null" || string(b) == `""` {
+ *f = 0
+ return nil
+ }
+ var n int
+ if err := json.Unmarshal(b, &n); err == nil {
+ *f = flexInt(n)
+ return nil
+ }
+ var s string
+ if err := json.Unmarshal(b, &s); err == nil {
+ v, err := strconv.Atoi(strings.TrimSpace(s))
+ if err != nil {
+ return fmt.Errorf("expected numeric string, got %q", s)
+ }
+ *f = flexInt(v)
+ return nil
+ }
+ return fmt.Errorf("expected number or numeric string, got %s", b)
+}
+
+func url(segment string) string {
+ return neturl.PathEscape(segment)
+}
+
+func (c *Client) post(ctx context.Context, path string, body map[string]any, out any) error {
+ payload := map[string]any{
+ "apikey": c.apiKey,
+ "secretapikey": c.secretAPIKey,
+ }
+ for k, v := range body {
+ payload[k] = v
+ }
+ buf, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("encoding request for %s: %w", path, err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(buf))
+ if err != nil {
+ return fmt.Errorf("building request for %s: %w", path, err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("calling porkbun API %s: %w", path, err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return fmt.Errorf("reading response from %s: %w", path, err)
+ }
+
+ var envelope struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ }
+ if err := json.Unmarshal(raw, &envelope); err != nil {
+ return fmt.Errorf("porkbun API %s returned HTTP %d with an unparseable body: %w", path, resp.StatusCode, err)
+ }
+ if envelope.Status != "SUCCESS" {
+ return &APIError{HTTPStatus: resp.StatusCode, Status: envelope.Status, Message: envelope.Message}
+ }
+ if out != nil {
+ if err := json.Unmarshal(raw, out); err != nil {
+ return fmt.Errorf("decoding response from %s: %w", path, err)
+ }
+ }
+ return nil
+}