mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-27 08:02:27 +00:00
585 lines
17 KiB
Go
585 lines
17 KiB
Go
// Package salesforce is Warmbly's native Salesforce sync: the REST client, the
|
|
// link between a Warmbly contact and its Lead or Contact, the activity outbox
|
|
// that logs campaign events as Tasks, the pull loop that keeps linked records
|
|
// current, and imports from list views and Salesforce Campaigns.
|
|
package salesforce
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/pkg/safehttp"
|
|
)
|
|
|
|
// APIVersion is the REST version every request targets.
|
|
const APIVersion = "v62.0"
|
|
|
|
// maxBatch is the record cap of one composite sObject collection call.
|
|
const maxBatch = 200
|
|
|
|
// TokenSource hands the client a usable access token and the org's API host.
|
|
// force asks for a fresh token after Salesforce refused the current one.
|
|
type TokenSource interface {
|
|
Token(ctx context.Context, force bool) (token, instanceURL string, err error)
|
|
}
|
|
|
|
// Usage is the org-wide API consumption Salesforce reports on every response.
|
|
type Usage struct {
|
|
Used int
|
|
Max int
|
|
}
|
|
|
|
// Client calls one connected Salesforce org.
|
|
type Client struct {
|
|
http *http.Client
|
|
tokens TokenSource
|
|
onUsage func(Usage)
|
|
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
// NewClient builds a client over a token source. onUsage, when set, receives
|
|
// the Sforce-Limit-Info reading of every response.
|
|
func NewClient(ts TokenSource, onUsage func(Usage)) *Client {
|
|
hc := safehttp.Client(30 * time.Second)
|
|
tr := safehttp.NewTransport()
|
|
tr.ResponseHeaderTimeout = 30 * time.Second // a composite write can outlast the default header wait
|
|
hc.Transport = tr
|
|
return &Client{
|
|
http: hc,
|
|
tokens: ts,
|
|
onUsage: onUsage,
|
|
}
|
|
}
|
|
|
|
// Calls is how many API requests this client has made.
|
|
func (c *Client) Calls() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.calls
|
|
}
|
|
|
|
// APIError is Salesforce's own refusal, kept whole so the activity log can show
|
|
// the errorCode an admin can act on.
|
|
type APIError struct {
|
|
Status int
|
|
Code string
|
|
Message string
|
|
Fields []string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
msg := e.Message
|
|
if msg == "" {
|
|
msg = http.StatusText(e.Status)
|
|
}
|
|
if e.Code != "" {
|
|
msg = e.Code + ": " + msg
|
|
}
|
|
if len(e.Fields) > 0 {
|
|
msg += " (" + strings.Join(e.Fields, ", ") + ")"
|
|
}
|
|
return msg
|
|
}
|
|
|
|
var (
|
|
// ErrSessionExpired means a fresh token was refused too.
|
|
ErrSessionExpired = errors.New("salesforce refused the session; reconnect required")
|
|
// ErrRateLimited means the org has spent its daily API allocation.
|
|
ErrRateLimited = errors.New("salesforce API request limit reached")
|
|
)
|
|
|
|
// IsCode reports whether err is a Salesforce refusal with this errorCode.
|
|
func IsCode(err error, code string) bool {
|
|
var ae *APIError
|
|
return errors.As(err, &ae) && ae.Code == code
|
|
}
|
|
|
|
// Retryable reports whether a failed call may succeed later unchanged.
|
|
func Retryable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, ErrRateLimited) {
|
|
return true
|
|
}
|
|
var ae *APIError
|
|
if errors.As(err, &ae) {
|
|
switch ae.Code {
|
|
case "UNABLE_TO_LOCK_ROW", "SERVER_UNAVAILABLE", "REQUEST_RUNNING_TOO_LONG", "QUERY_TIMEOUT":
|
|
return true
|
|
}
|
|
return ae.Status >= 500 || ae.Status == http.StatusTooManyRequests
|
|
}
|
|
// Transport failures (timeouts, resets) carry no API error at all.
|
|
return !errors.Is(err, ErrSessionExpired)
|
|
}
|
|
|
|
// Header is an extra request header for one call.
|
|
type Header struct{ Key, Value string }
|
|
|
|
// do sends one request. path is either a /services/... path or an absolute
|
|
// nextRecordsUrl-style path Salesforce handed back. A 401 is answered once with
|
|
// a forced token refresh.
|
|
func (c *Client) do(ctx context.Context, method, path string, body any, out any, headers ...Header) error {
|
|
var payload []byte
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload = b
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
token, instance, err := c.tokens.Token(ctx, attempt > 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if instance == "" || !strings.HasPrefix(path, "/") {
|
|
return errors.New("salesforce instance URL unknown; reconnect the integration")
|
|
}
|
|
var reader io.Reader
|
|
if payload != nil {
|
|
reader = bytes.NewReader(payload)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(instance, "/")+path, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
for _, h := range headers {
|
|
req.Header.Set(h.Key, h.Value)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
c.mu.Lock()
|
|
c.calls++
|
|
c.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
|
resp.Body.Close()
|
|
if u, ok := parseLimitInfo(resp.Header.Get("Sforce-Limit-Info")); ok && c.onUsage != nil {
|
|
c.onUsage(u)
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
if attempt == 0 {
|
|
continue
|
|
}
|
|
return ErrSessionExpired
|
|
}
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
if out != nil && len(raw) > 0 {
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
return nil
|
|
}
|
|
apiErr := parseAPIError(resp.StatusCode, raw)
|
|
if apiErr.Code == "REQUEST_LIMIT_EXCEEDED" {
|
|
return fmt.Errorf("%w: %s", ErrRateLimited, apiErr.Message)
|
|
}
|
|
return apiErr
|
|
}
|
|
return ErrSessionExpired
|
|
}
|
|
|
|
func parseAPIError(status int, raw []byte) *APIError {
|
|
out := &APIError{Status: status}
|
|
var list []struct {
|
|
Message string `json:"message"`
|
|
ErrorCode string `json:"errorCode"`
|
|
Fields []string `json:"fields"`
|
|
}
|
|
if json.Unmarshal(raw, &list) == nil && len(list) > 0 {
|
|
out.Code, out.Message, out.Fields = list[0].ErrorCode, list[0].Message, list[0].Fields
|
|
return out
|
|
}
|
|
// The OAuth endpoints answer with a single object instead.
|
|
var single struct {
|
|
Error string `json:"error"`
|
|
Description string `json:"error_description"`
|
|
}
|
|
if json.Unmarshal(raw, &single) == nil && single.Error != "" {
|
|
out.Code, out.Message = single.Error, single.Description
|
|
return out
|
|
}
|
|
out.Message = strings.TrimSpace(string(raw))
|
|
if r := []rune(out.Message); len(r) > 300 {
|
|
out.Message = string(r[:300])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseLimitInfo reads "api-usage=18/15000".
|
|
func parseLimitInfo(h string) (Usage, bool) {
|
|
for _, part := range strings.Split(h, ";") {
|
|
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
|
|
if !ok || k != "api-usage" {
|
|
continue
|
|
}
|
|
used, max, ok := strings.Cut(v, "/")
|
|
if !ok {
|
|
return Usage{}, false
|
|
}
|
|
u, err1 := strconv.Atoi(used)
|
|
m, err2 := strconv.Atoi(max)
|
|
if err1 != nil || err2 != nil {
|
|
return Usage{}, false
|
|
}
|
|
return Usage{Used: u, Max: m}, true
|
|
}
|
|
return Usage{}, false
|
|
}
|
|
|
|
func dataPath(rest string) string {
|
|
return "/services/data/" + APIVersion + rest
|
|
}
|
|
|
|
// Record is one row as Salesforce returns it, relationship fields nested.
|
|
type Record map[string]any
|
|
|
|
// String reads a field, following a dotted relationship path ("Owner.Name").
|
|
func (r Record) String(path string) string {
|
|
var cur any = map[string]any(r)
|
|
for _, part := range strings.Split(path, ".") {
|
|
m, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
cur = m[part]
|
|
}
|
|
switch v := cur.(type) {
|
|
case string:
|
|
return v
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
case bool:
|
|
return strconv.FormatBool(v)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// Bool reads a boolean field.
|
|
func (r Record) Bool(field string) bool {
|
|
b, _ := r[field].(bool)
|
|
return b
|
|
}
|
|
|
|
// Time reads a datetime field.
|
|
func (r Record) Time(field string) *time.Time {
|
|
s := r.String(field)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
for _, layout := range []string{"2006-01-02T15:04:05.000-0700", time.RFC3339Nano, "2006-01-02"} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
t = t.UTC()
|
|
return &t
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type queryPage struct {
|
|
Done bool `json:"done"`
|
|
TotalSize int `json:"totalSize"`
|
|
NextRecordsURL string `json:"nextRecordsUrl"`
|
|
Records []Record `json:"records"`
|
|
}
|
|
|
|
// Query runs SOQL and hands every page to fn until the result is exhausted,
|
|
// fn returns false, or max records (0 = no cap) have been read.
|
|
func (c *Client) Query(ctx context.Context, soql string, max int, fn func([]Record) bool) (total int, err error) {
|
|
path := dataPath("/query?q=" + url.QueryEscape(soql))
|
|
read := 0
|
|
for path != "" {
|
|
var page queryPage
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &page); err != nil {
|
|
return total, err
|
|
}
|
|
total = page.TotalSize
|
|
recs := page.Records
|
|
if max > 0 && read+len(recs) > max {
|
|
recs = recs[:max-read]
|
|
}
|
|
for _, r := range recs {
|
|
delete(r, "attributes")
|
|
}
|
|
read += len(recs)
|
|
if !fn(recs) || page.Done || (max > 0 && read >= max) {
|
|
return total, nil
|
|
}
|
|
path = page.NextRecordsURL
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// QueryAll is Query collected into one slice.
|
|
func (c *Client) QueryAll(ctx context.Context, soql string, max int) ([]Record, error) {
|
|
var out []Record
|
|
_, err := c.Query(ctx, soql, max, func(rs []Record) bool {
|
|
out = append(out, rs...)
|
|
return true
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// PicklistValue is one option of a picklist field.
|
|
type PicklistValue struct {
|
|
Value string `json:"value"`
|
|
Label string `json:"label"`
|
|
Active bool `json:"active"`
|
|
DefaultValue bool `json:"defaultValue"`
|
|
}
|
|
|
|
// Field is one field of an object's describe.
|
|
type Field struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
Length int `json:"length"`
|
|
Createable bool `json:"createable"`
|
|
Updateable bool `json:"updateable"`
|
|
Calculated bool `json:"calculated"`
|
|
AutoNumber bool `json:"autoNumber"`
|
|
Custom bool `json:"custom"`
|
|
Nillable bool `json:"nillable"`
|
|
DefaultedOn bool `json:"defaultedOnCreate"`
|
|
Restricted bool `json:"restrictedPicklist"`
|
|
PicklistValues []PicklistValue `json:"picklistValues"`
|
|
ReferenceTo []string `json:"referenceTo"`
|
|
}
|
|
|
|
// Describe is the subset of an object's describe the sync reads.
|
|
type Describe struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Createable bool `json:"createable"`
|
|
Updateable bool `json:"updateable"`
|
|
Queryable bool `json:"queryable"`
|
|
Fields []Field `json:"fields"`
|
|
}
|
|
|
|
// Field returns the named field, or nil.
|
|
func (d *Describe) Field(name string) *Field {
|
|
for i := range d.Fields {
|
|
if strings.EqualFold(d.Fields[i].Name, name) {
|
|
return &d.Fields[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Describe reads an object's metadata.
|
|
func (c *Client) Describe(ctx context.Context, object string) (*Describe, error) {
|
|
var d Describe
|
|
if err := c.do(ctx, http.MethodGet, dataPath("/sobjects/"+url.PathEscape(object)+"/describe"), nil, &d); err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// ListView is a saved list view of an object.
|
|
type ListView struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Name string `json:"developerName"`
|
|
}
|
|
|
|
// ListViews returns the list views the connected user can see on an object.
|
|
func (c *Client) ListViews(ctx context.Context, object string) ([]ListView, error) {
|
|
var out struct {
|
|
ListViews []ListView `json:"listviews"`
|
|
NextURL string `json:"nextRecordsUrl"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, dataPath("/sobjects/"+url.PathEscape(object)+"/listviews?limit=200"), nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out.ListViews {
|
|
out.ListViews[i].ID = NormalizeID(out.ListViews[i].ID)
|
|
}
|
|
return out.ListViews, nil
|
|
}
|
|
|
|
// ListViewQuery returns the SOQL a list view runs, so its whole result can be
|
|
// paged through /query instead of the 2,000-row results resource.
|
|
func (c *Client) ListViewQuery(ctx context.Context, object, id string) (string, error) {
|
|
var out struct {
|
|
Query string `json:"query"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, dataPath("/sobjects/"+url.PathEscape(object)+"/listviews/"+url.PathEscape(id)+"/describe"), nil, &out); err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(out.Query) == "" {
|
|
return "", errors.New("salesforce returned no query for this list view")
|
|
}
|
|
return out.Query, nil
|
|
}
|
|
|
|
// SaveResult is the outcome of one record in a collection write.
|
|
type SaveResult struct {
|
|
ID string `json:"id"`
|
|
Success bool `json:"success"`
|
|
Errors []struct {
|
|
StatusCode string `json:"statusCode"`
|
|
Message string `json:"message"`
|
|
Fields []string `json:"fields"`
|
|
} `json:"errors"`
|
|
}
|
|
|
|
// Err turns a failed result into an APIError.
|
|
func (r SaveResult) Err() error {
|
|
if r.Success {
|
|
return nil
|
|
}
|
|
if len(r.Errors) == 0 {
|
|
return &APIError{Status: http.StatusBadRequest, Message: "salesforce rejected the record"}
|
|
}
|
|
e := r.Errors[0]
|
|
return &APIError{Status: http.StatusBadRequest, Code: e.StatusCode, Message: e.Message, Fields: e.Fields}
|
|
}
|
|
|
|
// Create inserts records of one object, up to 200 per call, without letting one
|
|
// bad record fail the rest. Results are in input order.
|
|
func (c *Client) Create(ctx context.Context, object string, records []map[string]any, headers ...Header) ([]SaveResult, error) {
|
|
return c.collection(ctx, http.MethodPost, object, records, headers...)
|
|
}
|
|
|
|
// Update patches records (each carrying Id) of one object.
|
|
func (c *Client) Update(ctx context.Context, object string, records []map[string]any, headers ...Header) ([]SaveResult, error) {
|
|
return c.collection(ctx, http.MethodPatch, object, records, headers...)
|
|
}
|
|
|
|
func (c *Client) collection(ctx context.Context, method, object string, records []map[string]any, headers ...Header) ([]SaveResult, error) {
|
|
out := make([]SaveResult, 0, len(records))
|
|
for start := 0; start < len(records); start += maxBatch {
|
|
end := min(start+maxBatch, len(records))
|
|
chunk := make([]map[string]any, 0, end-start)
|
|
for _, r := range records[start:end] {
|
|
rec := make(map[string]any, len(r)+1)
|
|
for k, v := range r {
|
|
rec[k] = v
|
|
}
|
|
rec["attributes"] = map[string]string{"type": object}
|
|
chunk = append(chunk, rec)
|
|
}
|
|
var res []SaveResult
|
|
body := map[string]any{"allOrNone": false, "records": chunk}
|
|
if err := c.do(ctx, method, dataPath("/composite/sobjects"), body, &res, headers...); err != nil {
|
|
return out, err
|
|
}
|
|
for i := range res {
|
|
res[i].ID = NormalizeID(res[i].ID)
|
|
}
|
|
out = append(out, res...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Get reads one record with the named fields.
|
|
func (c *Client) Get(ctx context.Context, object, id string, fields []string) (Record, error) {
|
|
var out Record
|
|
path := dataPath("/sobjects/" + url.PathEscape(object) + "/" + url.PathEscape(id))
|
|
if len(fields) > 0 {
|
|
path += "?fields=" + url.QueryEscape(strings.Join(fields, ","))
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
delete(out, "attributes")
|
|
return out, nil
|
|
}
|
|
|
|
// IdentityIDs pulls the org and user ids out of the identity URL the token
|
|
// response carries (https://login.salesforce.com/id/<org>/<user>).
|
|
func IdentityIDs(identityURL string) (orgID, userID string) {
|
|
u, err := url.Parse(identityURL)
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(parts) >= 3 && parts[0] == "id" {
|
|
return NormalizeID(parts[1]), NormalizeID(parts[2])
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// Limits reads the org's daily API allocation.
|
|
func (c *Client) Limits(ctx context.Context) (Usage, error) {
|
|
var out map[string]struct {
|
|
Max int `json:"Max"`
|
|
Remaining int `json:"Remaining"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, dataPath("/limits"), nil, &out); err != nil {
|
|
return Usage{}, err
|
|
}
|
|
d := out["DailyApiRequests"]
|
|
return Usage{Used: d.Max - d.Remaining, Max: d.Max}, nil
|
|
}
|
|
|
|
// Quote renders s as a SOQL string literal.
|
|
func Quote(s string) string {
|
|
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`, "\t", `\t`, "\"", `\"`)
|
|
return "'" + r.Replace(s) + "'"
|
|
}
|
|
|
|
// QuoteList renders values as the body of a SOQL IN clause.
|
|
func QuoteList(values []string) string {
|
|
parts := make([]string, len(values))
|
|
for i, v := range values {
|
|
parts[i] = Quote(v)
|
|
}
|
|
return "(" + strings.Join(parts, ",") + ")"
|
|
}
|
|
|
|
// NormalizeID returns the 18-character form of a Salesforce id. The API speaks
|
|
// 18 characters and the UI 15; storing one form keeps comparisons honest.
|
|
func NormalizeID(id string) string {
|
|
id = strings.TrimSpace(id)
|
|
if len(id) != 15 {
|
|
return id
|
|
}
|
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"
|
|
suffix := make([]byte, 3)
|
|
for block := 0; block < 3; block++ {
|
|
n := 0
|
|
for i := 0; i < 5; i++ {
|
|
ch := id[block*5+i]
|
|
if ch >= 'A' && ch <= 'Z' {
|
|
n |= 1 << i
|
|
}
|
|
}
|
|
suffix[block] = alphabet[n]
|
|
}
|
|
return id + string(suffix)
|
|
}
|
|
|
|
// ValidID reports whether s looks like a Salesforce record id.
|
|
func ValidID(s string) bool {
|
|
if len(s) != 15 && len(s) != 18 {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|