diff options
Diffstat (limited to 'internal/porkbun/client.go')
| -rw-r--r-- | internal/porkbun/client.go | 248 |
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 +} |