Repository: amp-labs/connectors
Pull request #640 (2024-07-01 21:06:10)

tidy: remove restApiUrl provider option
# Changes

Some connectors have unnecessary Provider Options, which in fact can be derived from core variables of catalog.

Salesforce options equivalence:
* restApiUrl = BaseURL + `/services/data/v59.0`
* domain = BaseURL

There was ambiguity and several paths to the same values (API "sections"). All URLs can be formed via connector.getURL (for rest api, which is most common usage) and connector.getDomainURL (for special cases). 

Outreach options equivalence:
* restApiUrl = BaseURL + `api/v2`

Also for convenience `urlbuilder.URL` offers managment of query params and paths.

# Refactoring

Every connector tries to handle uri parts over and over while the utility method already exists for URL.
Adding tests which dictate how URL parts should be handled created some changes to AddPath and constructor.


# Testing

All tests are passing. Running read operation on Salesloft produces successful output.
![image](https://github.com/amp-labs/connectors/assets/60330780/2563e13e-c3f7-4c68-8efc-5ca0400bd798)

# Notes

There are no longer any ProviderOpts.



Patch:
diff --git a/.gitignore b/.gitignore
index 1490a7e8..642dc67e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -129,3 +129,5 @@ playground/
 time-formatter/*
 
 bin/
+
+.env
diff --git a/common/urlbuilder/url.go b/common/urlbuilder/url.go
index b74cebba..742886ad 100644
--- a/common/urlbuilder/url.go
+++ b/common/urlbuilder/url.go
@@ -19,8 +19,8 @@ type URL struct {
 }
 
 // New URL will be constructed given valid full url which may have query params.
-func New(base string) (*URL, error) {
-	delegate, err := url.Parse(base)
+func New(base string, path ...string) (*URL, error) {
+	delegate, err := url.Parse(cleanTrailingSlashes(base))
 	if err != nil {
 		return nil, errors.Join(err, ErrInvalidURL)
 	}
@@ -30,11 +30,14 @@ func New(base string) (*URL, error) {
 		return nil, errors.Join(err, ErrInvalidURL)
 	}
 
-	return &URL{
+	u := &URL{
 		delegate:           delegate,
 		queryParams:        values,
 		encodingExceptions: nil,
-	}, nil
+	}
+	u.AddPath(path...)
+
+	return u, nil
 }
 
 func (u *URL) WithQueryParamList(name string, values []string) {
@@ -92,7 +95,32 @@ func (u *URL) queryValuesToString() string {
 
 func (u *URL) AddPath(paths ...string) *URL {
 	// replace delegate with a new URL
-	u.delegate = u.delegate.JoinPath(paths...)
+	if len(paths) == 0 {
+		// nothing to be done here
+		return u
+	}
+
+	uriParts := make([]string, len(paths))
+
+	for i, p := range paths {
+		if i == len(paths)-1 {
+			// last index
+			p = cleanTrailingSlashes(p)
+		}
+
+		uriParts[i] = p
+	}
+
+	u.delegate = u.delegate.JoinPath(uriParts...)
 
 	return u
 }
+
+func cleanTrailingSlashes(link string) string {
+	found := true
+	for found {
+		link, found = strings.CutSuffix(link, "/")
+	}
+
+	return link
+}
diff --git a/dynamicscrm/connector.go b/dynamicscrm/connector.go
index c1f1d48e..a96ee7d9 100644
--- a/dynamicscrm/connector.go
+++ b/dynamicscrm/connector.go
@@ -2,7 +2,6 @@ package dynamicscrm
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
@@ -62,16 +61,7 @@ func (c *Connector) String() string {
 }
 
 func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
-	parts := []string{c.BaseURL, apiVersion, arg}
-	filtered := make([]string, 0)
-
-	for _, part := range parts {
-		if len(part) != 0 {
-			filtered = append(filtered, part)
-		}
-	}
-
-	return constructURL(strings.Join(filtered, "/"))
+	return constructURL(c.BaseURL, apiVersion, arg)
 }
 
 func (c *Connector) setBaseURL(newURL string) {
diff --git a/dynamicscrm/url.go b/dynamicscrm/url.go
index aea7ebe1..f051c6a1 100644
--- a/dynamicscrm/url.go
+++ b/dynamicscrm/url.go
@@ -9,8 +9,8 @@ var queryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
 	"%2C": ",",
 }
 
-func constructURL(base string) (*urlbuilder.URL, error) {
-	link, err := urlbuilder.New(base)
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/gong/connector.go b/gong/connector.go
index 1a7afcba..7ea8dc58 100644
--- a/gong/connector.go
+++ b/gong/connector.go
@@ -1,8 +1,6 @@
 package gong
 
 import (
-	"strings"
-
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
 	"github.com/amp-labs/connectors/common/urlbuilder"
@@ -50,16 +48,7 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 }
 
 func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
-	parts := []string{c.BaseURL, ApiVersion, arg}
-	filtered := make([]string, 0)
-
-	for _, part := range parts {
-		if len(part) != 0 {
-			filtered = append(filtered, part)
-		}
-	}
-
-	return constructURL(strings.Join(filtered, "/"))
+	return constructURL(c.BaseURL, ApiVersion, arg)
 }
 
 func (c *Connector) setBaseURL(newURL string) {
diff --git a/gong/url.go b/gong/url.go
index c1923d93..7f686fa4 100644
--- a/gong/url.go
+++ b/gong/url.go
@@ -6,8 +6,8 @@ var queryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
 	// none
 }
 
-func constructURL(base string) (*urlbuilder.URL, error) {
-	link, err := urlbuilder.New(base)
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/intercom/connector.go b/intercom/connector.go
index efeaf764..b4a5ca1b 100644
--- a/intercom/connector.go
+++ b/intercom/connector.go
@@ -2,7 +2,6 @@ package intercom
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
@@ -63,16 +62,7 @@ func (c *Connector) String() string {
 
 // nolint:unused
 func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
-	parts := []string{c.BaseURL, arg}
-	filtered := make([]string, 0)
-
-	for _, part := range parts {
-		if len(part) != 0 {
-			filtered = append(filtered, part)
-		}
-	}
-
-	return constructURL(strings.Join(filtered, "/"))
+	return constructURL(c.BaseURL, arg)
 }
 
 func (c *Connector) setBaseURL(newURL string) {
diff --git a/intercom/url.go b/intercom/url.go
index e0f60fb6..966b4e3b 100644
--- a/intercom/url.go
+++ b/intercom/url.go
@@ -7,8 +7,8 @@ var intercomQueryEncodingExceptions = map[string]string{ //nolint:gochecknogloba
 	"%3D": "=",
 }
 
-func constructURL(base string) (*urlbuilder.URL, error) {
-	link, err := urlbuilder.New(base)
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/outreach/connector.go b/outreach/connector.go
index 224f8d30..4e1ef0f6 100644
--- a/outreach/connector.go
+++ b/outreach/connector.go
@@ -1,14 +1,13 @@
 package outreach
 
 import (
-	"fmt"
-
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/providers"
 )
 
 const (
-	providerOptionRestApiURL = "restAPIURL"
+	apiVersion = "api/v2"
 )
 
 type Connector struct {
@@ -33,19 +32,22 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 		return nil, err
 	}
 
-	restApi, ok := providerInfo.GetOption(providerOptionRestApiURL)
-	if !ok {
-		return nil, fmt.Errorf("restAPIURL not set: %w", providers.ErrProviderOptionNotFound)
-	}
-
 	conn = &Connector{
 		Client: &common.JSONHTTPClient{
 			HTTPClient: params.Client.Caller,
 		},
-		BaseURL: restApi,
 	}
 
-	conn.Client.HTTPClient.Base = providerInfo.BaseURL
+	conn.setBaseURL(providerInfo.BaseURL)
 
 	return conn, nil
 }
+
+func (c *Connector) getApiURL(arg string) (*urlbuilder.URL, error) {
+	return constructURL(c.BaseURL, apiVersion, arg)
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/outreach/metadata.go b/outreach/metadata.go
index faa34004..36058791 100644
--- a/outreach/metadata.go
+++ b/outreach/metadata.go
@@ -3,7 +3,6 @@ package outreach
 import (
 	"context"
 	"encoding/json"
-	"net/url"
 
 	"github.com/amp-labs/connectors/common"
 )
@@ -27,12 +26,12 @@ func (c *Connector) ListObjectMetadata(ctx context.Context,
 
 	for _, obj := range objectNames {
 		// Constructing the  request url.
-		objURL, err := url.JoinPath(c.BaseURL, obj)
+		url, err := c.getApiURL(obj)
 		if err != nil {
 			return nil, err
 		}
 
-		res, err := c.Client.Get(ctx, objURL)
+		res, err := c.Client.Get(ctx, url.String())
 		if err != nil {
 			objMetadata.Errors[obj] = err
 
diff --git a/outreach/read.go b/outreach/read.go
index fae8996c..a2bcabef 100644
--- a/outreach/read.go
+++ b/outreach/read.go
@@ -2,41 +2,36 @@ package outreach
 
 import (
 	"context"
-	"net/url"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 )
 
 func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
-	var (
-		res    *common.JSONHTTPResponse
-		err    error
-		fields []string
-	)
-
-	if len(config.NextPage) > 0 {
-		// If NextPage is set, then we're reading the next page of results.
-		// The NextPage URL has all the necessary parameters.
-		res, err = c.Client.Get(ctx, config.NextPage.String())
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		fullURL, err := url.JoinPath(c.BaseURL, config.ObjectName)
-		if err != nil {
-			return nil, err
-		}
+	url, err := c.buildReadURL(config)
+	if err != nil {
+		return nil, err
+	}
 
-		res, err = c.Client.Get(ctx, fullURL)
-		if err != nil {
-			return nil, err
-		}
+	res, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
 	}
 
 	return common.ParseResult(res, getTotalSize,
 		getRecords,
 		getNextRecordsURL,
 		getMarshaledData,
-		fields,
+		config.Fields,
 	)
 }
+
+func (c *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, error) {
+	if len(config.NextPage) > 0 {
+		// If NextPage is set, then we're reading the next page of results.
+		// The NextPage URL has all the necessary parameters.
+		return constructURL(config.NextPage.String())
+	}
+
+	return c.getApiURL(config.ObjectName)
+}
diff --git a/outreach/url.go b/outreach/url.go
new file mode 100644
index 00000000..7770e742
--- /dev/null
+++ b/outreach/url.go
@@ -0,0 +1,18 @@
+package outreach
+
+import "github.com/amp-labs/connectors/common/urlbuilder"
+
+var queryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
+	// none
+}
+
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
+	if err != nil {
+		return nil, err
+	}
+
+	link.AddEncodingExceptions(queryEncodingExceptions)
+
+	return link, nil
+}
diff --git a/outreach/write.go b/outreach/write.go
index 55d525e5..f694188b 100644
--- a/outreach/write.go
+++ b/outreach/write.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"net/url"
 
 	"github.com/amp-labs/connectors/common"
 )
@@ -21,16 +20,13 @@ var JSONAPIContentTypeHeader = common.Header{ //nolint:gochecknoglobals
 func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
 	var write common.WriteMethod
 
-	URL, err := url.JoinPath(c.BaseURL, config.ObjectName)
+	url, err := c.getApiURL(config.ObjectName)
 	if err != nil {
 		return nil, err
 	}
 
 	if len(config.RecordId) > 0 {
-		URL, err = url.JoinPath(URL, config.RecordId)
-		if err != nil {
-			return nil, err
-		}
+		url.AddPath(config.RecordId)
 
 		write = c.Client.Patch
 	} else {
@@ -41,7 +37,7 @@ func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*comm
 	data := make(map[string]any)
 	data["data"] = config.RecordData
 
-	res, err := write(ctx, URL, data, JSONAPIContentTypeHeader)
+	res, err := write(ctx, url.String(), data, JSONAPIContentTypeHeader)
 	if err != nil {
 		return nil, err
 	}
diff --git a/providers/outreach.go b/providers/outreach.go
index ff2a673b..d29cb7a3 100644
--- a/providers/outreach.go
+++ b/providers/outreach.go
@@ -29,8 +29,5 @@ func init() {
 			Subscribe: false,
 			Write:     false,
 		},
-		ProviderOpts: ProviderOpts{
-			"restAPIURL": "https://api.outreach.io/api/v2",
-		},
 	})
 }
diff --git a/providers/salesforce.go b/providers/salesforce.go
index df3ec744..fda48420 100644
--- a/providers/salesforce.go
+++ b/providers/salesforce.go
@@ -31,9 +31,5 @@ func init() {
 			Subscribe: false,
 			Write:     true,
 		},
-		ProviderOpts: ProviderOpts{
-			"restApiUrl": "https://{{.workspace}}.my.salesforce.com/services/data/v59.0",
-			"domain":     "{{.workspace}}.my.salesforce.com",
-		},
 	})
 }
diff --git a/salesforce/bulk.go b/salesforce/bulk.go
index f65a55a9..85d20456 100644
--- a/salesforce/bulk.go
+++ b/salesforce/bulk.go
@@ -7,7 +7,6 @@ import (
 	"fmt"
 	"io"
 	"net/http"
-	"net/url"
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
@@ -130,26 +129,17 @@ func (c *Connector) BulkWrite( //nolint:funlen,cyclop
 	return result, nil
 }
 
-func joinURLPath(baseURL string, paths ...string) (string, error) {
-	location, err := url.JoinPath(baseURL, paths...)
-	if err != nil {
-		return "", errors.Join(err, common.ErrInvalidPathJoin)
-	}
-
-	return location, nil
-}
-
 func (c *Connector) createJob(ctx context.Context, body map[string]any) (*common.JSONHTTPResponse, error) {
-	location, err := joinURLPath(c.BaseURL, "jobs/ingest")
+	location, err := c.getRestApiURL("jobs/ingest")
 	if err != nil {
 		return nil, err
 	}
 
-	return c.Client.Post(ctx, location, body)
+	return c.Client.Post(ctx, location.String(), body)
 }
 
 func (c *Connector) uploadCSV(ctx context.Context, jobId string, csvData io.Reader) ([]byte, error) {
-	location, err := joinURLPath(c.BaseURL, fmt.Sprintf("jobs/ingest/%s/batches", jobId))
+	location, err := c.getRestApiURL(fmt.Sprintf("jobs/ingest/%s/batches", jobId))
 	if err != nil {
 		return nil, err
 	}
@@ -162,7 +152,7 @@ func (c *Connector) uploadCSV(ctx context.Context, jobId string, csvData io.Read
 		)
 	}
 
-	return c.Client.PutCSV(ctx, location, data)
+	return c.Client.PutCSV(ctx, location.String(), data)
 }
 
 func (c *Connector) completeUpload(ctx context.Context, jobId string) (*common.JSONHTTPResponse, error) {
@@ -170,21 +160,21 @@ func (c *Connector) completeUpload(ctx context.Context, jobId string) (*common.J
 		"state": JobStateUploadComplete,
 	}
 
-	location, err := joinURLPath(c.BaseURL, "jobs/ingest/"+jobId)
+	location, err := c.getRestApiURL("jobs/ingest/" + jobId)
 	if err != nil {
 		return nil, err
 	}
 
-	return c.Client.Patch(ctx, location, updateLoadCompleteBody)
+	return c.Client.Patch(ctx, location.String(), updateLoadCompleteBody)
 }
 
 func (c *Connector) GetJobInfo(ctx context.Context, jobId string) (*GetJobInfoResult, error) {
-	location, err := joinURLPath(c.BaseURL, "jobs/ingest", jobId)
+	location, err := c.getRestApiURL("jobs/ingest", jobId)
 	if err != nil {
 		return nil, err
 	}
 
-	rsp, err := c.Client.Get(ctx, location)
+	rsp, err := c.Client.Get(ctx, location.String())
 	if err != nil {
 		return nil, fmt.Errorf("getGetInfo failed: %w", errors.Join(err, common.ErrRequestFailed))
 	}
@@ -222,12 +212,12 @@ func (c *Connector) GetJobResults(ctx context.Context, jobId string) (*JobResult
 }
 
 func (c *Connector) GetSuccessfulJobResults(ctx context.Context, jobId string) (*http.Response, error) {
-	location, err := joinURLPath(c.BaseURL, fmt.Sprintf("jobs/ingest/%s/successfulResults", jobId))
+	location, err := c.getRestApiURL(fmt.Sprintf("jobs/ingest/%s/successfulResults", jobId))
 	if err != nil {
 		return nil, err
 	}
 
-	req, err := common.MakeJSONGetRequest(ctx, location, nil)
+	req, err := common.MakeJSONGetRequest(ctx, location.String(), nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create get request: %w", err)
 	}
@@ -238,12 +228,12 @@ func (c *Connector) GetSuccessfulJobResults(ctx context.Context, jobId string) (
 }
 
 func (c *Connector) getJobResults(ctx context.Context, jobId string) (*http.Response, error) {
-	location, err := joinURLPath(c.BaseURL, fmt.Sprintf("jobs/ingest/%s/failedResults", jobId))
+	location, err := c.getRestApiURL(fmt.Sprintf("jobs/ingest/%s/failedResults", jobId))
 	if err != nil {
 		return nil, err
 	}
 
-	req, err := common.MakeJSONGetRequest(ctx, location, nil)
+	req, err := common.MakeJSONGetRequest(ctx, location.String(), nil)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create get request: %w", err)
 	}
@@ -491,7 +481,7 @@ func (c *Connector) BulkQuery(
 	ctx context.Context,
 	query string,
 ) (*GetJobInfoResult, error) {
-	location, err := joinURLPath(c.BaseURL, "jobs/query")
+	location, err := c.getRestApiURL("jobs/query")
 	if err != nil {
 		return nil, err
 	}
@@ -501,7 +491,7 @@ func (c *Connector) BulkQuery(
 		"query":     query,
 	}
 
-	res, err := c.Client.Post(ctx, location, jobBody)
+	res, err := c.Client.Post(ctx, location.String(), jobBody)
 	if err != nil {
 		return nil, fmt.Errorf("bulk query failed: %w", err)
 	}
@@ -513,12 +503,12 @@ func (c *Connector) GetBulkQueryInfo(
 	ctx context.Context,
 	jobId string,
 ) (*GetJobInfoResult, error) {
-	location, err := joinURLPath(c.BaseURL, "jobs/query", jobId)
+	location, err := c.getRestApiURL("jobs/query", jobId)
 	if err != nil {
 		return nil, err
 	}
 
-	res, err := c.Client.Get(ctx, location)
+	res, err := c.Client.Get(ctx, location.String())
 	if err != nil {
 		return nil, fmt.Errorf(
 			"failed to get bulk query info for job '%s': %w",
@@ -534,12 +524,12 @@ func (c *Connector) GetBulkQueryResults(
 	ctx context.Context,
 	jobId string,
 ) (*http.Response, error) {
-	location, err := joinURLPath(c.BaseURL, fmt.Sprintf("jobs/query/%s/results", jobId))
+	location, err := c.getRestApiURL(fmt.Sprintf("jobs/query/%s/results", jobId))
 	if err != nil {
 		return nil, err
 	}
 
-	req, err := common.MakeJSONGetRequest(ctx, location, []common.Header{
+	req, err := common.MakeJSONGetRequest(ctx, location.String(), []common.Header{
 		{
 			Key:   "Accept",
 			Value: "text/csv",
diff --git a/salesforce/connector.go b/salesforce/connector.go
index 4a063f43..8a1b4d24 100644
--- a/salesforce/connector.go
+++ b/salesforce/connector.go
@@ -4,22 +4,19 @@ import (
 	"fmt"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/providers"
 )
 
 const (
-	providerOptionRestApiURL = "restApiUrl"
-	providerOptionDomain     = "domain"
-)
-
-const (
-	apiVersion    = "59.0"
-	versionPrefix = "v"
+	apiVersion                 = "59.0"
+	versionPrefix              = "v"
+	restAPISuffix              = "/services/data/" + apiVersion
+	uriToolingEventRelayConfig = "tooling/sobjects/EventRelayConfig"
 )
 
 // Connector is a Salesforce connector.
 type Connector struct {
-	Domain  string
 	BaseURL string
 	Client  *common.JSONHTTPClient
 }
@@ -50,27 +47,48 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 		return nil, err
 	}
 
-	restApi, ok := providerInfo.GetOption(providerOptionRestApiURL)
-	if !ok {
-		return nil, fmt.Errorf("restApiUrl not set: %w", providers.ErrProviderOptionNotFound)
-	}
-
-	domain, ok := providerInfo.GetOption(providerOptionDomain)
-	if !ok {
-		return nil, fmt.Errorf("domain not set: %w", providers.ErrProviderOptionNotFound)
-	}
-
 	conn = &Connector{
-		BaseURL: restApi,
-		Domain:  domain,
 		Client: &common.JSONHTTPClient{
 			HTTPClient: params.Client.Caller,
 		},
 	}
 
-	conn.Client.HTTPClient.Base = providerInfo.BaseURL
+	conn.setBaseURL(providerInfo.BaseURL)
 	conn.Client.HTTPClient.ErrorHandler = conn.interpretError
 	conn.Client.ErrorPostProcessor.Process = handleError
 
 	return conn, nil
 }
+
+// Provider returns the connector provider.
+func (c *Connector) Provider() providers.Provider {
+	return providers.Salesforce
+}
+
+// String returns a string representation of the connector, which is useful for logging / debugging.
+func (c *Connector) String() string {
+	return fmt.Sprintf("%s.Connector", c.Provider())
+}
+
+func (c *Connector) getRestApiURL(paths ...string) (*urlbuilder.URL, error) {
+	parts := append([]string{
+		restAPISuffix, // scope URLs to API version
+	}, paths...)
+
+	return constructURL(c.BaseURL, parts...)
+}
+
+func (c *Connector) getDomainURL(paths ...string) (*urlbuilder.URL, error) {
+	return constructURL(c.BaseURL, paths...)
+}
+
+// nolint: lll
+// https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_eventrelayconfig.htm?q=EventRelayConfig
+func (c *Connector) getURIPartEventRelayConfig(paths ...string) (*urlbuilder.URL, error) {
+	return constructURL(uriToolingEventRelayConfig, paths...)
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/salesforce/events.go b/salesforce/events.go
index bacbb641..7855bdf3 100644
--- a/salesforce/events.go
+++ b/salesforce/events.go
@@ -186,10 +186,11 @@ func (c *Connector) CreateEventRelayConfig(
 	return cfg, nil
 }
 
+// RunEventRelay
 // nolint: lll
 // https://developer.salesforce.com/docs/atlas.en-us.api_tooling.meta/api_tooling/tooling_api_objects_eventrelayconfig.htm?q=EventRelayConfig
 func (c *Connector) RunEventRelay(ctx context.Context, cfg *EventRelayConfig) error {
-	location, err := joinURLPath("tooling/sobjects/EventRelayConfig", cfg.Id)
+	location, err := c.getURIPartEventRelayConfig(cfg.Id)
 	if err != nil {
 		return err
 	}
@@ -201,7 +202,8 @@ func (c *Connector) RunEventRelay(ctx context.Context, cfg *EventRelayConfig) er
 		},
 	}
 
-	_, err = c.Client.Patch(ctx, location, config) // patch returns no content with 204. If it fails, it will return an error.
+	// patch returns no content with 204. If it fails, it will return an error.
+	_, err = c.Client.Patch(ctx, location.String(), config)
 	if err != nil {
 		slog.Error("Run EventRelayConfig", "error", err)
 
@@ -253,12 +255,12 @@ type Credential interface {
 }
 
 func (c *Connector) postToSFAPI(ctx context.Context, body any, path string, entity string) (*SFAPIResponseBody, error) {
-	location, err := joinURLPath(c.BaseURL, path)
+	location, err := c.getRestApiURL(path)
 	if err != nil {
 		return nil, err
 	}
 
-	resp, err := c.Client.Post(ctx, location, body)
+	resp, err := c.Client.Post(ctx, location.String(), body)
 	if err != nil {
 		return nil, err
 	}
diff --git a/salesforce/metadata.go b/salesforce/metadata.go
index 85500ff6..103ad492 100644
--- a/salesforce/metadata.go
+++ b/salesforce/metadata.go
@@ -39,7 +39,7 @@ func (c *Connector) ListObjectMetadata(
 	}
 
 	// Construct endpoint for the request
-	compositeRequestEndpoint, err := url.JoinPath(c.BaseURL, "composite")
+	compositeRequestEndpoint, err := c.getRestApiURL("composite")
 	if err != nil {
 		return nil, err
 	}
@@ -47,7 +47,7 @@ func (c *Connector) ListObjectMetadata(
 	// Make the request
 	result, err := c.Client.Post(
 		ctx,
-		compositeRequestEndpoint,
+		compositeRequestEndpoint.String(),
 		compositeRequest{
 			CompositeRequest: requests,
 			// If we fail to fetch metadata for one object, we don't want to fail the entire request.
diff --git a/salesforce/metadataApi.go b/salesforce/metadataApi.go
index 259f6799..6d7b8d5a 100644
--- a/salesforce/metadataApi.go
+++ b/salesforce/metadataApi.go
@@ -8,7 +8,6 @@ import (
 	"io"
 	"log/slog"
 	"net/http"
-	"net/url"
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
@@ -67,14 +66,14 @@ func (c *Connector) prepareXMLRequest(
 ) (*http.Request, error) {
 	data := preparePayload(metadata, tok.AccessToken)
 
-	endPointURL, err := url.JoinPath(c.Client.HTTPClient.Base, "services/Soap/m/"+APIVersionSOAP())
+	url, err := c.getDomainURL("services/Soap/m/" + APIVersionSOAP())
 	if err != nil {
 		return nil, errors.Join(ErrCreatingRequest, err)
 	}
 
 	byteData := []byte(data)
 
-	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, bytes.NewBuffer(byteData))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewBuffer(byteData))
 	if err != nil {
 		return nil, errors.Join(ErrCreatingRequest, err)
 	}
diff --git a/salesforce/provider.go b/salesforce/provider.go
deleted file mode 100644
index 0ae5c122..00000000
--- a/salesforce/provider.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package salesforce
-
-import (
-	"github.com/amp-labs/connectors/providers"
-)
-
-// Provider returns the connector provider.
-func (c *Connector) Provider() providers.Provider {
-	return providers.Salesforce
-}
diff --git a/salesforce/read.go b/salesforce/read.go
index 79b93b3e..95b70e80 100644
--- a/salesforce/read.go
+++ b/salesforce/read.go
@@ -3,49 +3,21 @@ package salesforce
 import (
 	"context"
 	"fmt"
-	"net/url"
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 )
 
-// Read reads data from Salesforce. By default it will read all rows (backfill). However, if Since is set,
+// Read reads data from Salesforce. By default, it will read all rows (backfill). However, if Since is set,
 // it will read only rows that have been updated since the specified time.
 func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
-	var (
-		rsp *common.JSONHTTPResponse
-		err error
-	)
-
-	if len(config.NextPage) > 0 {
-		// If NextPage is set, then we're reading the next page of results.
-		// All that matters is the NextPage URL, the fields are ignored.
-		location, joinErr := url.JoinPath("https://"+c.Domain, config.NextPage.String())
-		if joinErr != nil {
-			return nil, joinErr
-		}
-
-		rsp, err = c.Client.Get(ctx, location)
-	} else {
-		// If NextPage is not set, then we're reading the first page of results.
-		// We need to construct the SOQL query and then make the request.
-		soql, soqlErr := makeSOQL(config)
-		if soqlErr != nil {
-			return nil, soqlErr
-		}
-
-		// Encode the SOQL query as a URL parameter
-		qp := url.Values{}
-		qp.Add("q", soql)
-
-		location, joinErr := url.JoinPath(c.BaseURL, "/query/")
-		if joinErr != nil {
-			return nil, joinErr
-		}
-
-		rsp, err = c.Client.Get(ctx, location+"?"+qp.Encode())
+	url, err := c.buildReadURL(config)
+	if err != nil {
+		return nil, err
 	}
 
+	rsp, err := c.Client.Get(ctx, url.String())
 	if err != nil {
 		return nil, err
 	}
@@ -60,6 +32,30 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	)
 }
 
+func (c *Connector) buildReadURL(config common.ReadParams) (*urlbuilder.URL, error) {
+	if len(config.NextPage) != 0 {
+		// If NextPage is set, then we're reading the next page of results.
+		// All that matters is the NextPage URL, the fields are ignored.
+		return c.getDomainURL(config.NextPage.String())
+	}
+
+	// If NextPage is not set, then we're reading the first page of results.
+	// We need to construct the SOQL query and then make the request.
+	url, err := c.getRestApiURL("query")
+	if err != nil {
+		return nil, err
+	}
+
+	soql, err := makeSOQL(config)
+	if err != nil {
+		return nil, err
+	}
+
+	url.WithQueryParam("q", soql)
+
+	return url, nil
+}
+
 // makeSOQL returns the SOQL query for the desired read operation.
 func makeSOQL(config common.ReadParams) (string, error) {
 	// Make sure we have at least one field
diff --git a/salesforce/string.go b/salesforce/string.go
deleted file mode 100644
index abc04c9c..00000000
--- a/salesforce/string.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package salesforce
-
-import "fmt"
-
-// String returns a string representation of the connector, which is useful for logging / debugging.
-func (c *Connector) String() string {
-	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Domain)
-}
diff --git a/salesforce/url.go b/salesforce/url.go
new file mode 100644
index 00000000..95d31961
--- /dev/null
+++ b/salesforce/url.go
@@ -0,0 +1,18 @@
+package salesforce
+
+import "github.com/amp-labs/connectors/common/urlbuilder"
+
+var queryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
+	// none
+}
+
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
+	if err != nil {
+		return nil, err
+	}
+
+	link.AddEncodingExceptions(queryEncodingExceptions)
+
+	return link, nil
+}
diff --git a/salesforce/write.go b/salesforce/write.go
index 736fd81d..74191e98 100644
--- a/salesforce/write.go
+++ b/salesforce/write.go
@@ -2,7 +2,6 @@ package salesforce
 
 import (
 	"context"
-	"net/url"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/spyzhov/ajson"
@@ -10,26 +9,18 @@ import (
 
 // Write will write data to Salesforce.
 func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
-	var (
-		rsp *common.JSONHTTPResponse
-		err error
-	)
-
-	location, joinErr := url.JoinPath(c.BaseURL+"/sobjects", config.ObjectName)
-	if joinErr != nil {
-		return nil, joinErr
+	url, err := c.getRestApiURL("sobjects", config.ObjectName)
+	if err != nil {
+		return nil, err
 	}
 
 	if config.RecordId != "" {
-		location, joinErr = url.JoinPath(location, config.RecordId)
-		if joinErr != nil {
-			return nil, joinErr
-		}
+		url.AddPath(config.RecordId)
 		// Salesforce allows for PATCH method override
-		location += "?_HttpMethod=PATCH"
+		url.WithQueryParam("_HttpMethod", "PATCH")
 	}
 
-	rsp, err = c.Client.Post(ctx, location, config.RecordData)
+	rsp, err := c.Client.Post(ctx, url.String(), config.RecordData)
 	if err != nil {
 		return nil, err
 	}
diff --git a/salesloft/connector.go b/salesloft/connector.go
index 230f921d..012e477e 100644
--- a/salesloft/connector.go
+++ b/salesloft/connector.go
@@ -2,7 +2,6 @@ package salesloft
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
@@ -57,16 +56,7 @@ func (c *Connector) String() string {
 }
 
 func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
-	parts := []string{c.BaseURL, apiVersion, arg}
-	filtered := make([]string, 0)
-
-	for _, part := range parts {
-		if len(part) != 0 {
-			filtered = append(filtered, part)
-		}
-	}
-
-	return urlbuilder.New(strings.Join(filtered, "/"))
+	return urlbuilder.New(c.BaseURL, apiVersion, arg)
 }
 
 func (c *Connector) setBaseURL(newURL string) {


Test patch:
diff --git a/common/urlbuilder/url_test.go b/common/urlbuilder/url_test.go
index 9488d936..555ddfd6 100644
--- a/common/urlbuilder/url_test.go
+++ b/common/urlbuilder/url_test.go
@@ -3,6 +3,7 @@ package urlbuilder
 import (
 	"errors"
 	"reflect"
+	"strings"
 	"testing"
 )
 
@@ -116,3 +117,78 @@ func TestWithQueryParam(t *testing.T) { // nolint:funlen
 		})
 	}
 }
+
+func TestAddPath(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    []string
+		expected string
+	}{
+		{
+			name:     "No path",
+			input:    nil,
+			expected: "",
+		},
+		{
+			name:     "Parts without slashes",
+			input:    []string{"carts", "personal", "products"},
+			expected: "/carts/personal/products",
+		},
+		{
+			name:     "Slashes at the beginning/end of URI part",
+			input:    []string{"carts/", "personal/", "/products"},
+			expected: "/carts/personal/products",
+		},
+		{
+			name:     "Double and triple slashes",
+			input:    []string{"carts///", "/personal//", "/products"},
+			expected: "/carts/personal/products",
+		},
+		{
+			name:     "Empty URI parts are ignored",
+			input:    []string{"wishlists", "", "//house", "", "items"},
+			expected: "/wishlists/house/items",
+		},
+		{
+			name:     "Slashes as URI parts are ignored",
+			input:    []string{"coupons", "/", "/", "redeem"},
+			expected: "/coupons/redeem",
+		},
+		{
+			name:     "Trailing slash is missing",
+			input:    []string{"search", "/"},
+			expected: "/search",
+		},
+		{
+			name:     "Trailing slashes are missing",
+			input:    []string{"search", "///"},
+			expected: "/search",
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			base := "https://google.com"
+			// Deliberately pass extra slashes which should have no effect
+			u, err := New(base + "////")
+			if err != nil {
+				t.Fatalf("bad test (%v)", tt.name)
+			}
+
+			// apply modifications from test scenario
+			fullURL := u.AddPath(tt.input...).String()
+			// We are testing only the path from root.
+			path, _ := strings.CutPrefix(fullURL, base)
+
+			if !reflect.DeepEqual(path, tt.expected) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expected, path)
+			}
+		})
+	}
+}