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 }