// Package client implements a thin HTTP client for the Dokploy API. // // Dokploy exposes its entire tRPC router over REST via @dokploy/trpc-openapi. // Every procedure is reachable at `/api/.`: // // queries -> GET with flat query parameters // mutations -> POST with a flat JSON body // // Authentication is a static API token sent in the `x-api-key` header. package client import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) const defaultTimeout = 60 * time.Second // Client talks to a single Dokploy instance. type Client struct { baseURL string token string http *http.Client // Several create endpoints validate an organizationId that the server then // overrides with the token's own organization. It is fetched once and // reused. orgOnce sync.Once orgID string orgErr error } // OrganizationID returns the organization the API token belongs to. // // Dokploy's Zod schemas for `sshKey.create`, `certificates.create` and friends // require an organizationId even though the router replaces it with the // session's own organization, so the provider has to supply something valid. func (c *Client) OrganizationID(ctx context.Context) (string, error) { c.orgOnce.Do(func() { raw, err := c.Query(ctx, "user.get", nil) if err != nil { c.orgErr = fmt.Errorf("resolving the token's organization via user.get: %w", err) return } var payload struct { OrganizationID string `json:"organizationId"` } if err := json.Unmarshal(raw, &payload); err != nil { c.orgErr = fmt.Errorf("decoding user.get response: %w", err) return } if payload.OrganizationID == "" { c.orgErr = fmt.Errorf("user.get did not report an organizationId") return } c.orgID = payload.OrganizationID }) return c.orgID, c.orgErr } // New builds a client for the given host (e.g. "https://dokploy.example.com"). // A trailing slash and/or a trailing "/api" are both tolerated. func New(host, token string, timeout time.Duration, insecureClient *http.Client) (*Client, error) { trimmed := strings.TrimRight(strings.TrimSpace(host), "/") if trimmed == "" { return nil, fmt.Errorf("host must not be empty") } trimmed = strings.TrimSuffix(trimmed, "/api") u, err := url.Parse(trimmed) if err != nil { return nil, fmt.Errorf("invalid host %q: %w", host, err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("host must start with http:// or https://, got %q", host) } httpClient := insecureClient if httpClient == nil { httpClient = &http.Client{} } if timeout <= 0 { timeout = defaultTimeout } httpClient.Timeout = timeout return &Client{baseURL: trimmed + "/api", token: token, http: httpClient}, nil } // APIError is a structured Dokploy error response. type APIError struct { StatusCode int Message string `json:"message"` Code string `json:"code"` Path string // FieldErrors holds Zod per-field validation messages, when present. FieldErrors map[string][]string raw string } func (e *APIError) Error() string { var b strings.Builder fmt.Fprintf(&b, "dokploy API error (HTTP %d)", e.StatusCode) if e.Code != "" { fmt.Fprintf(&b, " %s", e.Code) } if e.Path != "" { fmt.Fprintf(&b, " on %s", e.Path) } if e.Message != "" { fmt.Fprintf(&b, ": %s", e.Message) } for field, msgs := range e.FieldErrors { fmt.Fprintf(&b, "\n - %s: %s", field, strings.Join(msgs, "; ")) } if e.Message == "" && len(e.FieldErrors) == 0 && e.raw != "" { fmt.Fprintf(&b, ": %s", truncate(e.raw, 500)) } return b.String() } // IsNotFound reports whether err represents a missing resource. Terraform uses // this to drop a resource from state instead of failing the run. func IsNotFound(err error) bool { var apiErr *APIError if !errors.As(err, &apiErr) { return false } if apiErr.StatusCode == http.StatusNotFound || apiErr.Code == "NOT_FOUND" { return true } // Dokploy frequently surfaces a missing row as a 500 with a message from // the service layer rather than a typed NOT_FOUND. msg := strings.ToLower(apiErr.Message) return strings.Contains(msg, "not found") || strings.Contains(msg, "doesn't exist") } // Query performs a GET against a tRPC query procedure. Non-nil values in input // are flattened into query parameters. func (c *Client) Query(ctx context.Context, procedure string, input map[string]any) (json.RawMessage, error) { endpoint := c.baseURL + "/" + procedure if len(input) > 0 { values := url.Values{} for k, v := range input { if v == nil { continue } values.Set(k, stringify(v)) } if encoded := values.Encode(); encoded != "" { endpoint += "?" + encoded } } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } return c.do(req, procedure) } // Mutate performs a POST against a tRPC mutation procedure with a JSON body. func (c *Client) Mutate(ctx context.Context, procedure string, input map[string]any) (json.RawMessage, error) { if input == nil { input = map[string]any{} } body, err := json.Marshal(input) if err != nil { return nil, fmt.Errorf("encoding request body for %s: %w", procedure, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+procedure, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") return c.do(req, procedure) } func (c *Client) do(req *http.Request, procedure string) (json.RawMessage, error) { req.Header.Set("x-api-key", c.token) req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("calling %s: %w", procedure, err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response from %s: %w", procedure, err) } if resp.StatusCode >= 400 { return nil, parseAPIError(resp.StatusCode, procedure, raw) } return json.RawMessage(raw), nil } func parseAPIError(status int, procedure string, raw []byte) *APIError { apiErr := &APIError{StatusCode: status, Path: procedure, raw: string(raw)} var envelope struct { Message string `json:"message"` Code string `json:"code"` Data struct { Code string `json:"code"` Path string `json:"path"` ZodError *struct { FieldErrors map[string][]string `json:"fieldErrors"` FormErrors []string `json:"formErrors"` } `json:"zodError"` } `json:"data"` } if err := json.Unmarshal(raw, &envelope); err != nil { return apiErr } apiErr.Message = envelope.Message apiErr.Code = firstNonEmpty(envelope.Code, envelope.Data.Code) if envelope.Data.Path != "" { apiErr.Path = envelope.Data.Path } if z := envelope.Data.ZodError; z != nil { if len(z.FieldErrors) > 0 { apiErr.FieldErrors = z.FieldErrors } if len(z.FormErrors) > 0 { if apiErr.FieldErrors == nil { apiErr.FieldErrors = map[string][]string{} } apiErr.FieldErrors["(form)"] = z.FormErrors } } return apiErr } func stringify(v any) string { switch t := v.(type) { case string: return t case bool: return strconv.FormatBool(t) case int: return strconv.Itoa(t) case int64: return strconv.FormatInt(t, 10) case float64: return strconv.FormatFloat(t, 'f', -1, 64) default: return fmt.Sprintf("%v", t) } } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }