Repository: amp-labs/connectors
Pull request #603 (2024-06-25 21:12:05)

[ENG-1257] feat: Add WithWorkspace, connectors delegate to paramsbuilder
# Changes

* paramsbuilder.Workspace is used by relevant connectors, method returns substitutions map making usage concise
* Based on Hubstpot formulated behaviour of paramsbuilder.Module with fallback module
* all connectors delegate to paramsbuilder, shorter format


Patch:
diff --git a/common/paramsbuilder/params.go b/common/paramsbuilder/params.go
index ce6c4fd7..72fec466 100644
--- a/common/paramsbuilder/params.go
+++ b/common/paramsbuilder/params.go
@@ -17,8 +17,9 @@ type ParamAssurance interface {
 }
 
 var (
-	ErrMissingClient    = errors.New("http client not set")
-	ErrMissingWorkspace = errors.New("missing workspace name")
+	ErrMissingClient     = errors.New("http client not set")
+	ErrMissingWorkspace  = errors.New("missing workspace name")
+	ErrNoSupportedModule = errors.New("no supported module was chosen")
 )
 
 // Client params sets up authenticated proxy HTTP client
@@ -78,20 +79,54 @@ func (p *Workspace) WithWorkspace(workspaceRef string) {
 	p.Name = workspaceRef
 }
 
+func (p *Workspace) Substitution() map[string]string {
+	return map[string]string{"workspace": p.Name}
+}
+
 // Module params adds suffix to URL controlling API versions.
 // This is relevant where there are several APIs for different product areas or sub-products, and the APIs
 // are versioned differently or have different ways of constructing URLs from object names.
 type Module struct {
-	Suffix string
+	Suffix    string
+	supported []APIModule
+	fallback  *APIModule
 }
 
 func (p *Module) ValidateParams() error {
-	// url suffix may be omitted
+	// making sure the provided module is supported.
+	// If the provided module is not supported, use fallback.
+	if !p.isSupported() {
+		if p.fallback == nil {
+			// not supported and user didn't provide a fallback
+			return ErrNoSupportedModule
+		}
+
+		// replace with fallback module
+		p.Suffix = p.fallback.String()
+
+		// even fallback is not supported
+		if !p.isSupported() {
+			return ErrNoSupportedModule
+		}
+	}
+
 	return nil
 }
 
-func (p *Module) WithModule(module APIModule) {
+func (p *Module) WithModule(module APIModule, supported []APIModule, defaultModule *APIModule) {
 	p.Suffix = module.String()
+	p.supported = supported
+	p.fallback = defaultModule
+}
+
+func (p *Module) isSupported() bool {
+	for _, mod := range p.supported {
+		if p.Suffix == mod.String() {
+			return true
+		}
+	}
+
+	return false
 }
 
 type APIModule struct {
diff --git a/common/parse.go b/common/parse.go
index bd937948..6ef0e6a1 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -6,7 +6,11 @@ import (
 	"github.com/spyzhov/ajson"
 )
 
-type NextPageFunc func(*ajson.Node) (string, error)
+type (
+	ListSizeFunc func(*ajson.Node) (int64, error)
+	NextPageFunc func(*ajson.Node) (string, error)
+	RecordsFunc  func(*ajson.Node) ([]map[string]any, error)
+)
 
 // ParseResult parses the response from a provider into a ReadResult. A 2xx return type is assumed.
 // The sizeFunc, recordsFunc, nextPageFunc, and marshalFunc are used to extract the relevant data from the response.
diff --git a/connector/connector.go b/connector/connector.go
index 31a181ca..18a4ad2b 100644
--- a/connector/connector.go
+++ b/connector/connector.go
@@ -15,30 +15,12 @@ func NewConnector(
 	provider providers.Provider,
 	opts ...Option,
 ) (conn *Connector, outErr error) {
-	defer func() {
-		if re := recover(); re != nil {
-			tmp, ok := re.(error)
-			if !ok {
-				panic(re)
-			}
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
 
-			outErr = tmp
-			conn = nil
-		}
-	}()
-
-	// Set up basic params
-	params := &connectorParams{}
-	params.provider = provider
-
-	// Apply options & verify
-	for _, opt := range opts {
-		opt(params)
-	}
-
-	var err error
-
-	params, err = params.prepare()
+	params, err := parameters{provider: provider}.FromOptions(opts...)
 	if err != nil {
 		return nil, err
 	}
@@ -46,11 +28,15 @@ func NewConnector(
 	// Create connector
 	conn = &Connector{
 		provider: params.provider,
-		Client:   params.client,
+		Client: &common.JSONHTTPClient{
+			HTTPClient: params.Client.Caller,
+		},
 	}
 
 	// Read provider info & replace catalog variables with given substitutions, if any
-	providerInfo, err := providers.ReadInfo(conn.provider, &params.substitutions)
+	substitution := params.Workspace.Substitution()
+
+	providerInfo, err := providers.ReadInfo(conn.provider, &substitution)
 	if err != nil {
 		return nil, err
 	}
diff --git a/connector/params.go b/connector/params.go
index 6ff52838..58a8f2ca 100644
--- a/connector/params.go
+++ b/connector/params.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"github.com/amp-labs/connectors/providers"
 	"golang.org/x/oauth2"
 )
@@ -18,63 +19,54 @@ var (
 	ErrMissingProvider = errors.New("missing provider")
 )
 
-type Option func(*connectorParams)
+type Option func(*parameters)
 
-type connectorParams struct {
-	provider      providers.Provider
-	client        *common.JSONHTTPClient
-	substitutions map[string]string
+type parameters struct {
+	provider providers.Provider
+	paramsbuilder.Client
+	paramsbuilder.Workspace
 }
 
-func (p *connectorParams) prepare() (*connectorParams, error) {
-	if p.provider == "" {
-		return nil, ErrMissingProvider
-	}
-
-	if p.client == nil {
-		return nil, ErrMissingClient
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
 	}
 
-	return p, nil
+	return params, params.ValidateParams()
 }
 
-// WithCatalogSubstitutions sets the provider values to use while making substitutions &
-// reading from providers.yaml. If the provider values are not set, the connector
-// will use error out.
-func WithCatalogSubstitutions(substitutions map[string]string) Option {
-	return func(params *connectorParams) {
-		params.substitutions = substitutions
+func (p parameters) ValidateParams() error {
+	if p.provider == "" {
+		return ErrMissingProvider
 	}
+
+	// workspace is optional
+
+	return errors.Join(
+		p.Client.ValidateParams(),
+	)
 }
 
 // WithClient sets the http client to use for the connector.
-func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
-	opts ...common.OAuthOption,
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
 ) Option {
-	return func(params *connectorParams) {
-		options := []common.OAuthOption{
-			common.WithOAuthClient(client),
-			common.WithOAuthConfig(config),
-			common.WithOAuthToken(token),
-		}
-
-		oauthClient, err := common.NewOAuthHTTPClient(ctx, append(options, opts...)...)
-		if err != nil {
-			panic(err) // caught in NewConnector
-		}
-
-		WithAuthenticatedClient(oauthClient)(params)
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
 	}
 }
 
 // WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
 func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *connectorParams) {
-		params.client = &common.JSONHTTPClient{
-			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
-			},
-		}
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
+
+// WithWorkspace sets workspace which is used as substitution for URL templates.
+func WithWorkspace(workspaceRef string) Option {
+	return func(params *parameters) {
+		params.WithWorkspace(workspaceRef)
 	}
 }
diff --git a/docusign/connector.go b/docusign/connector.go
index 8990f33f..d7a58d91 100644
--- a/docusign/connector.go
+++ b/docusign/connector.go
@@ -11,32 +11,20 @@ type Connector struct {
 }
 
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
-	defer func() {
-		if re := recover(); re != nil {
-			tmp, ok := re.(error)
-			if !ok {
-				panic(re)
-			}
-
-			outErr = tmp
-			conn = nil
-		}
-	}()
-
-	params := &docusignParams{}
-	for _, opt := range opts {
-		opt(params)
-	}
-
-	var err error
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
 
-	params, err = params.prepare()
+	params, err := parameters{}.FromOptions(opts...)
 	if err != nil {
 		return nil, err
 	}
 
 	conn = &Connector{
-		Client: params.client,
+		Client: &common.JSONHTTPClient{
+			HTTPClient: params.Client.Caller,
+		},
 	}
 
 	// Read provider info
diff --git a/docusign/params.go b/docusign/params.go
index a752bde7..36e3dd65 100644
--- a/docusign/params.go
+++ b/docusign/params.go
@@ -2,53 +2,46 @@ package docusign
 
 import (
 	"context"
+	"errors"
 	"net/http"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"golang.org/x/oauth2"
 )
 
-type docusignParams struct {
-	client *common.JSONHTTPClient
+type Option func(params *parameters)
+
+type parameters struct {
+	paramsbuilder.Client
+}
+
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
+	}
+
+	return params, params.ValidateParams()
 }
 
-type Option func(params *docusignParams)
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+	)
+}
 
 // WithClient sets the http client to use for the connector. Saves some boilerplate.
-func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
-	opts ...common.OAuthOption,
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
 ) Option {
-	return func(params *docusignParams) {
-		options := []common.OAuthOption{
-			common.WithOAuthClient(client),
-			common.WithOAuthConfig(config),
-			common.WithOAuthToken(token),
-		}
-
-		oauthClient, err := common.NewOAuthHTTPClient(ctx, append(options, opts...)...)
-		if err != nil {
-			panic(err) // caught in NewConnector
-		}
-
-		WithAuthenticatedClient(oauthClient)(params)
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
 	}
 }
 
 func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *docusignParams) {
-		params.client = &common.JSONHTTPClient{
-			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
-			},
-		}
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
 	}
 }
-
-func (params *docusignParams) prepare() (*docusignParams, error) {
-	if params.client == nil {
-		return nil, ErrMissingClient
-	}
-
-	return params, nil
-}
diff --git a/go.mod b/go.mod
index b799ab72..82aa294a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.21.0
 
 require (
 	github.com/PuerkitoBio/goquery v1.9.2
-	github.com/antchfx/xmlquery v1.4.0
+	github.com/antchfx/xmlquery v1.4.1
 	github.com/brianvoe/gofakeit/v6 v6.28.0
 	github.com/gertd/go-pluralize v0.2.1
 	github.com/go-playground/validator v9.31.0+incompatible
@@ -21,7 +21,7 @@ require (
 
 require (
 	github.com/andybalholm/cascadia v1.3.2 // indirect
-	github.com/antchfx/xpath v1.3.0 // indirect
+	github.com/antchfx/xpath v1.3.1 // indirect
 	github.com/fsnotify/fsnotify v1.7.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
diff --git a/go.sum b/go.sum
index 5640f3bd..258ad7e6 100644
--- a/go.sum
+++ b/go.sum
@@ -2,11 +2,11 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
 github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
-github.com/antchfx/xmlquery v1.4.0 h1:xg2HkfcRK2TeTbdb0m1jxCYnvsPaGY/oeZWTGqX/0hA=
-github.com/antchfx/xmlquery v1.4.0/go.mod h1:Ax2aeaeDjfIw3CwXKDQ0GkwZ6QlxoChlIBP+mGnDFjI=
+github.com/antchfx/xmlquery v1.4.1 h1:YgpSwbeWvLp557YFTi8E3z6t6/hYjmFEtiEKbDfEbl0=
+github.com/antchfx/xmlquery v1.4.1/go.mod h1:lKezcT8ELGt8kW5L+ckFMTbgdR61/odpPgDv8Gvi1fI=
 github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
-github.com/antchfx/xpath v1.3.0 h1:nTMlzGAK3IJ0bPpME2urTuFL76o4A96iYvoKFHRXJgc=
-github.com/antchfx/xpath v1.3.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
+github.com/antchfx/xpath v1.3.1 h1:PNbFuUqHwWl0xRjvUPjJ95Agbmdj2uzzIwmQKgu4oCk=
+github.com/antchfx/xpath v1.3.1/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
 github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
diff --git a/gong/connector.go b/gong/connector.go
index 8caa830e..5d2ddb8f 100644
--- a/gong/connector.go
+++ b/gong/connector.go
@@ -1,9 +1,11 @@
 package gong
 
 import (
-	"net/http"
+	"strings"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/providers"
 )
 
@@ -14,54 +16,53 @@ type Connector struct {
 	Client  *common.JSONHTTPClient
 }
 
-func WithCatalogSubstitutions(substitutions map[string]string) Option {
-	return func(params *gongParams) {
-		params.substitutions = substitutions
-	}
-}
-
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 	defer common.PanicRecovery(func(cause error) {
 		outErr = cause
 		conn = nil
 	})
 
-	params := &gongParams{}
-	for _, opt := range opts {
-		opt(params)
-	}
-
-	var err error
-
-	params, err = params.prepare()
+	params, err := parameters{}.FromOptions(opts...)
 	if err != nil {
 		return nil, err
 	}
 
+	httpClient := params.Client.Caller
+	conn = &Connector{
+		Client: &common.JSONHTTPClient{
+			HTTPClient: httpClient,
+		},
+	}
+
 	// Read provider info
-	providerInfo, err := providers.ReadInfo(providers.Gong, &map[string]string{
-		"workspace": params.Workspace.Name,
-	})
+	providerInfo, err := providers.ReadInfo(conn.Provider(), nil)
 	if err != nil {
 		return nil, err
 	}
 
-	conn = &Connector{
-		Client:  params.client,
-		BaseURL: providerInfo.BaseURL,
-	}
-
+	// connector and its client must mirror base url and provide its own error parser
 	conn.setBaseURL(providerInfo.BaseURL)
-	conn.Client.HTTPClient.ErrorHandler = conn.interpretError
+	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
+		JSON: conn.interpretJSONError,
+	}.Handle
 
 	return conn, nil
 }
 
+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, "/"))
+}
+
 func (c *Connector) setBaseURL(newURL string) {
 	c.BaseURL = newURL
 	c.Client.HTTPClient.Base = newURL
 }
-
-func (c *Connector) interpretError(res *http.Response, body []byte) error {
-	return common.InterpretError(res, body)
-}
diff --git a/gong/errors.go b/gong/errors.go
index e3039438..31e1ce89 100644
--- a/gong/errors.go
+++ b/gong/errors.go
@@ -1,15 +1,32 @@
 package gong
 
 import (
-	"errors"
-)
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
 
-var (
-	ErrMissingClient = errors.New("JSON http client not set")
-	ErrNotArray      = errors.New("results data is not an array")
-	ErrNotObject     = errors.New("record is not an object")
+	"github.com/amp-labs/connectors/common/interpreter"
 )
 
-func (c *Connector) HandleError(err error) error {
-	return err
+func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //nolint:cyclop
+	var payload ResponseError
+	if err := json.Unmarshal(body, &payload); err != nil {
+		return fmt.Errorf("interpretJSONError general: %w %w", interpreter.ErrUnmarshal, err)
+	}
+
+	return payload.CombineErr(interpreter.DefaultStatusCodeMappingToErr(res, body))
+}
+
+type ResponseError struct {
+	RequestId string   `json:"requestId"`
+	Errors    []string `json:"errors"`
+}
+
+func (r ResponseError) CombineErr(base error) error {
+	if len(r.Errors) == 0 {
+		return base
+	}
+
+	return fmt.Errorf("%w: %v", base, strings.Join(r.Errors, ","))
 }
diff --git a/gong/params.go b/gong/params.go
index 447aad43..9c340888 100644
--- a/gong/params.go
+++ b/gong/params.go
@@ -10,16 +10,15 @@ import (
 	"golang.org/x/oauth2"
 )
 
-type gongParams struct {
-	client *common.JSONHTTPClient
-	paramsbuilder.Workspace
-	paramsbuilder.APIModule
-	substitutions map[string]string
-}
+// Option is a function which mutates the connector configuration.
+type Option func(params *parameters)
 
-type Option func(params *gongParams)
+type parameters struct {
+	paramsbuilder.Client
+}
 
-func (params *gongParams) FromOptions(opts ...Option) (*gongParams, error) {
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
 	for _, opt := range opts {
 		opt(params)
 	}
@@ -27,49 +26,22 @@ func (params *gongParams) FromOptions(opts ...Option) (*gongParams, error) {
 	return params, params.ValidateParams()
 }
 
-func (params *gongParams) ValidateParams() error {
+func (p parameters) ValidateParams() error {
 	return errors.Join(
-		params.Workspace.ValidateParams(),
+		p.Client.ValidateParams(),
 	)
 }
 
-func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
 ) Option {
-	return func(params *gongParams) {
-		oauthclient, err := common.NewOAuthHTTPClient(
-			ctx, common.WithOAuthClient(client),
-			common.WithOAuthConfig(config),
-			common.WithOAuthToken(token),
-		)
-		if err != nil {
-			panic(err)
-		}
-
-		WithAuthenticatedClient(oauthclient)(params)
-	}
-}
-
-func WithModule(module paramsbuilder.APIModule) Option {
-	return func(params *gongParams) {
-		params.APIModule = module
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
 	}
 }
 
 func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *gongParams) {
-		params.client = &common.JSONHTTPClient{
-			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
-			},
-		}
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
 	}
 }
-
-func (params *gongParams) prepare() (*gongParams, error) {
-	if params.client == nil {
-		return nil, ErrMissingClient
-	}
-
-	return params, nil
-}
diff --git a/gong/parse.go b/gong/parse.go
index 49635348..367ec97b 100644
--- a/gong/parse.go
+++ b/gong/parse.go
@@ -2,83 +2,31 @@ package gong
 
 import (
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
 	"github.com/spyzhov/ajson"
 )
 
-// getNextRecords returns the token or empty string if there are no more records.
-func getNextRecordsURL(node *ajson.Node) (string, error) {
-	recordsNode, err := node.GetKey("records")
-	if err != nil {
-		return "", err
-	}
-
-	if !recordsNode.HasKey("cursor") {
-		return "", nil
+func makeGetTotalSize(objectName string) common.ListSizeFunc {
+	return func(node *ajson.Node) (int64, error) {
+		return jsonquery.New(node).ArraySize(objectName)
 	}
-
-	cursorNode, err := recordsNode.GetKey("cursor")
-	if err != nil {
-		return "", err
-	}
-
-	nextPage := cursorNode.MustString()
-
-	return nextPage, nil
 }
 
-// getRecords returns the records from the response.
-func getRecords(node *ajson.Node, objectName string) ([]map[string]interface{}, error) {
-	records, err := node.GetKey(objectName)
-	if err != nil {
-		return nil, ErrNotArray
-	}
-
-	if !records.IsArray() {
-		return nil, ErrNotArray
-	}
-
-	arr := records.MustArray()
-	out := make([]map[string]interface{}, 0, len(arr))
-
-	for _, v := range arr {
-		if !v.IsObject() {
-			return nil, ErrNotObject
-		}
-
-		data, err := v.Unpack()
+func makeGetRecords(objectName string) common.RecordsFunc {
+	return func(node *ajson.Node) ([]map[string]any, error) {
+		// items are stored in array named after the API object
+		arr, err := jsonquery.New(node).Array(objectName, false)
 		if err != nil {
 			return nil, err
 		}
 
-		m, ok := data.(map[string]interface{})
-		if !ok {
-			return nil, ErrNotObject
-		}
-
-		out = append(out, m)
+		return jsonquery.Convertor.ArrayToMap(arr)
 	}
-
-	return out, nil
 }
 
-// getTotalSize returns the total number of records that match the query.
-func getTotalSize(node *ajson.Node) (int64, error) {
-	recordsNode, err := node.GetKey("records")
-	if err != nil {
-		return 0, common.ErrParseError
-	}
-
-	totalRecordsNode, err := recordsNode.GetKey("currentPageSize")
-	if err != nil {
-		return 0, common.ErrParseError
-	}
-
-	totalRecords, err := totalRecordsNode.GetNumeric()
-	if err != nil {
-		return 0, err
-	}
-
-	return int64(totalRecords), nil
+// getNextRecords returns the token or empty string if there are no more records.
+func getNextRecordsURL(node *ajson.Node) (string, error) {
+	return jsonquery.New(node, "records").StrWithDefault("cursor", "")
 }
 
 // getMarshaledData accepts a list of records and returns a list of structured data ([]ReadResultRow).
diff --git a/gong/read.go b/gong/read.go
index 0473439c..d3e532ec 100644
--- a/gong/read.go
+++ b/gong/read.go
@@ -2,43 +2,30 @@ package gong
 
 import (
 	"context"
-	"net/url"
 
 	"github.com/amp-labs/connectors/common"
-	"github.com/spyzhov/ajson"
 )
 
 func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
-	var (
-		res *common.JSONHTTPResponse
-
-		fields []string
-	)
-
-	fullURL, err := url.JoinPath(c.BaseURL, ApiVersion, config.ObjectName)
+	url, err := c.getURL(config.ObjectName)
 	if err != nil {
 		return nil, err
 	}
 
 	if len(config.NextPage) != 0 { // not the first page, add a cursor
-		fullURL = fullURL + "?cursor=" + config.NextPage.String()
-	}
-
-	if config.Fields != nil {
-		fields = config.Fields
+		url.WithQueryParam("cursor", config.NextPage.String())
 	}
 
-	res, err = c.Client.Get(ctx, fullURL)
+	res, err := c.Client.Get(ctx, url.String())
 	if err != nil {
 		return nil, err
 	}
 
-	return common.ParseResult(res, getTotalSize,
-		func(node *ajson.Node) ([]map[string]interface{}, error) {
-			return getRecords(node, config.ObjectName)
-		},
+	return common.ParseResult(res,
+		makeGetTotalSize(config.ObjectName),
+		makeGetRecords(config.ObjectName),
 		getNextRecordsURL,
 		getMarshaledData,
-		fields,
+		config.Fields,
 	)
 }
diff --git a/gong/url.go b/gong/url.go
new file mode 100644
index 00000000..c1923d93
--- /dev/null
+++ b/gong/url.go
@@ -0,0 +1,18 @@
+package gong
+
+import "github.com/amp-labs/connectors/common/urlbuilder"
+
+var queryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
+	// none
+}
+
+func constructURL(base string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base)
+	if err != nil {
+		return nil, err
+	}
+
+	link.AddEncodingExceptions(queryEncodingExceptions)
+
+	return link, nil
+}
diff --git a/hubspot/connector.go b/hubspot/connector.go
index aed69897..2fed7862 100644
--- a/hubspot/connector.go
+++ b/hubspot/connector.go
@@ -14,26 +14,12 @@ type Connector struct {
 
 // NewConnector returns a new Hubspot connector.
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
-	defer func() {
-		if re := recover(); re != nil {
-			tmp, ok := re.(error)
-			if !ok {
-				panic(re)
-			}
-
-			outErr = tmp
-			conn = nil
-		}
-	}()
-
-	params := &hubspotParams{}
-	for _, opt := range opts {
-		opt(params)
-	}
-
-	var err error
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
 
-	params, err = params.prepare()
+	params, err := parameters{}.FromOptions(opts...)
 	if err != nil {
 		return nil, err
 	}
@@ -44,14 +30,20 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 		return nil, err
 	}
 
-	params.client.HTTPClient.Base = providerInfo.BaseURL
 	conn = &Connector{
-		BaseURL: params.client.HTTPClient.Base,
-		Module:  params.module,
-		Client:  params.client,
+		Client: &common.JSONHTTPClient{
+			HTTPClient: params.Client.Caller,
+		},
+		Module: params.Module.Suffix,
 	}
 
+	conn.setBaseURL(providerInfo.BaseURL)
 	conn.Client.HTTPClient.ErrorHandler = conn.interpretError
 
 	return conn, nil
 }
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/hubspot/modules.go b/hubspot/modules.go
index d90f8d0d..de8e5f52 100644
--- a/hubspot/modules.go
+++ b/hubspot/modules.go
@@ -1,40 +1,21 @@
 package hubspot
 
 import (
-	"fmt"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
 )
 
-type APIModule struct {
-	Label   string // e.g. "crm"
-	Version string // e.g. "v3"
-}
-
 // ModuleCRM is the module used for accessing standard CRM objects.
-var ModuleCRM = APIModule{ // nolint: gochecknoglobals
+var ModuleCRM = paramsbuilder.APIModule{ // nolint: gochecknoglobals
 	Label:   "crm",
 	Version: "v3",
 }
 
 // ModuleEmpty is used for proxying requests through.
-var ModuleEmpty = APIModule{ // nolint: gochecknoglobals
+var ModuleEmpty = paramsbuilder.APIModule{ // nolint: gochecknoglobals
 	Label:   "",
 	Version: "",
 }
 
 // supportedModules represents currently working and supported modules within the Hubspot connector.
 // Any added module should be appended added here.
-var supportedModules = []APIModule{ModuleCRM} // nolint: gochecknoglobals
-
-func (a APIModule) String() string {
-	return fmt.Sprintf("%s/%s", a.Label, a.Version)
-}
-
-func supportsModule(module string) bool {
-	for _, mod := range supportedModules {
-		if module == mod.String() {
-			return true
-		}
-	}
-
-	return false
-}
+var supportedModules = []paramsbuilder.APIModule{ModuleCRM} // nolint: gochecknoglobals
diff --git a/hubspot/params.go b/hubspot/params.go
index 126e51c3..d0ac45ee 100644
--- a/hubspot/params.go
+++ b/hubspot/params.go
@@ -2,10 +2,11 @@ package hubspot
 
 import (
 	"context"
+	"errors"
 	"net/http"
-	"strings"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"golang.org/x/oauth2"
 )
 
@@ -15,67 +16,51 @@ const (
 )
 
 // Option is a function which mutates the hubspot connector configuration.
-type Option func(params *hubspotParams)
+type Option func(params *parameters)
 
-// WithClient sets the http client to use for the connector. Saves some boilerplate.
-func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
-	opts ...common.OAuthOption,
-) Option {
-	return func(params *hubspotParams) {
-		options := []common.OAuthOption{
-			common.WithOAuthClient(client),
-			common.WithOAuthConfig(config),
-			common.WithOAuthToken(token),
-		}
-
-		oauthClient, err := common.NewOAuthHTTPClient(ctx, append(options, opts...)...)
-		if err != nil {
-			panic(err) // caught in NewConnector
-		}
-
-		WithAuthenticatedClient(oauthClient)(params)
-	}
+// parameters is the internal configuration for the hubspot connector.
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Module
 }
 
-// WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
-func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *hubspotParams) {
-		params.client = &common.JSONHTTPClient{
-			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
-			},
-		}
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
 	}
-}
 
-// WithModule sets the hubspot API module to use for the connector. It's required.
-func WithModule(module APIModule) Option {
-	return func(params *hubspotParams) {
-		path := []string{module.Label, module.Version}
-		params.module = strings.Join(path, "/")
-	}
+	return params, params.ValidateParams()
 }
 
-// hubspotParams is the internal configuration for the hubspot connector.
-type hubspotParams struct {
-	client *common.JSONHTTPClient // required
-	module string                 // required
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+		p.Module.ValidateParams(),
+	)
 }
 
-// prepare finalizes and validates the connector configuration, and returns an error if it's invalid.
-func (p *hubspotParams) prepare() (out *hubspotParams, err error) {
-	if p.client == nil {
-		return nil, ErrMissingClient
+// WithClient sets the http client to use for the connector. Saves some boilerplate.
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
+) Option {
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
 	}
+}
 
-	// making sure the provided module is supported.
-	// If the provide module is not supprted. defaults to CRM.
-	if !supportsModule(p.module) {
-		p.module = ModuleCRM.String()
+// WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
 	}
+}
 
-	return p, nil
+// WithModule sets the hubspot API module to use for the connector. It's required.
+func WithModule(module paramsbuilder.APIModule) Option {
+	return func(params *parameters) {
+		params.WithModule(module, supportedModules, &ModuleCRM)
+	}
 }
 
 func requiresFiltering(config common.ReadParams) bool {
diff --git a/mock/connector.go b/mock/connector.go
index 0447bd98..eee0f8e4 100644
--- a/mock/connector.go
+++ b/mock/connector.go
@@ -17,17 +17,10 @@ type Connector struct {
 }
 
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
-	defer func() {
-		if re := recover(); re != nil {
-			tmp, ok := re.(error)
-			if !ok {
-				panic(re)
-			}
-
-			outErr = tmp
-			conn = nil
-		}
-	}()
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
 
 	params := &mockParams{
 		read: func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error) {
diff --git a/mock/params.go b/mock/params.go
index 2c1c4013..b3d9955e 100644
--- a/mock/params.go
+++ b/mock/params.go
@@ -23,8 +23,7 @@ func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
 	return func(params *mockParams) {
 		params.client = &common.JSONHTTPClient{
 			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
+				Client: client,
 			},
 		}
 	}
diff --git a/outreach/connector.go b/outreach/connector.go
index 43ec3901..dcffb98b 100644
--- a/outreach/connector.go
+++ b/outreach/connector.go
@@ -17,26 +17,12 @@ type Connector struct {
 }
 
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
-	defer func() {
-		if re := recover(); re != nil {
-			tmp, ok := re.(error)
-			if !ok {
-				panic(re)
-			}
-
-			outErr = tmp
-			conn = nil
-		}
-	}()
-
-	params := &outreachParams{}
-	for _, opt := range opts {
-		opt(params)
-	}
-
-	var err error
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
 
-	params, err = params.prepare()
+	params, err := parameters{}.FromOptions(opts...)
 	if err != nil {
 		return nil, err
 	}
@@ -52,10 +38,14 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 		return nil, fmt.Errorf("restAPIURL not set: %w", providers.ErrProviderOptionNotFound)
 	}
 
-	params.client.HTTPClient.Base = providerInfo.BaseURL
-
-	return &Connector{
-		Client:  params.client,
+	conn = &Connector{
+		Client: &common.JSONHTTPClient{
+			HTTPClient: params.Client.Caller,
+		},
 		BaseURL: restApi,
-	}, nil
+	}
+
+	conn.Client.HTTPClient.Base = providerInfo.BaseURL
+
+	return conn, nil
 }
diff --git a/outreach/params.go b/outreach/params.go
index a31ac5a8..7da3348c 100644
--- a/outreach/params.go
+++ b/outreach/params.go
@@ -2,49 +2,45 @@ package outreach
 
 import (
 	"context"
+	"errors"
 	"net/http"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"golang.org/x/oauth2"
 )
 
-type outreachParams struct {
-	client *common.JSONHTTPClient
-}
+type Option func(params *parameters)
 
-type Option func(params *outreachParams)
+type parameters struct {
+	paramsbuilder.Client
+}
 
-func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
-) Option {
-	return func(params *outreachParams) {
-		oauthclient, err := common.NewOAuthHTTPClient(
-			ctx, common.WithOAuthClient(client),
-			common.WithOAuthConfig(config),
-			common.WithOAuthToken(token),
-		)
-		if err != nil {
-			panic(err)
-		}
-
-		WithAuthenticatedClient(oauthclient)(params)
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
 	}
+
+	return params, params.ValidateParams()
 }
 
-func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *outreachParams) {
-		params.client = &common.JSONHTTPClient{
-			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
-			},
-		}
-	}
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+	)
 }
 
-func (params *outreachParams) prepare() (*outreachParams, error) {
-	if params.client == nil {
-		return nil, ErrMissingClient
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
+) Option {
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
 	}
+}
 
-	return params, nil
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
 }
diff --git a/providers/Makefile b/providers/Makefile
index 341fbda6..d2996543 100644
--- a/providers/Makefile
+++ b/providers/Makefile
@@ -1,5 +1,8 @@
 # Generate Go types from OpenAPI spec.
 
-.PHONY: gen
-gen:
-	oapi-codegen --config=oapi.config.yaml --o=types.gen.go types.yaml
+CATALOG_YAML = https://raw.githubusercontent.com/amp-labs/openapi/main/catalog/catalog.yaml
+
+
+.PHONY: gen/catalog
+gen/catalog:
+	oapi-codegen --config=oapi.config.yaml --o=types.gen.go $(CATALOG_YAML)
diff --git a/providers/domo.go b/providers/domo.go
index ac55a2d6..a338c33c 100644
--- a/providers/domo.go
+++ b/providers/domo.go
@@ -10,7 +10,7 @@ func init() {
 		Oauth2Opts: &Oauth2Opts{
 			GrantType:                 ClientCredentials,
 			TokenURL:                  "https://api.domo.com/oauth/token",
-			ExplicitScopesRequired:    false,
+			ExplicitScopesRequired:    true,
 			ExplicitWorkspaceRequired: false,
 			TokenMetadataFields: TokenMetadataFields{
 				ScopesField:      "scope",
diff --git a/providers/freshdesk.go b/providers/freshdesk.go
new file mode 100644
index 00000000..8b461204
--- /dev/null
+++ b/providers/freshdesk.go
@@ -0,0 +1,23 @@
+package providers
+
+const Freshdesk Provider = "freshdesk"
+
+func init() {
+	SetInfo(Freshdesk, ProviderInfo{
+		AuthType: Basic,
+		BaseURL:  "https://{{.workspace}}.freshdesk.com",
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+		PostAuthInfoNeeded: false,
+	})
+}
diff --git a/providers/mailgun.go b/providers/mailgun.go
new file mode 100644
index 00000000..68a336b0
--- /dev/null
+++ b/providers/mailgun.go
@@ -0,0 +1,23 @@
+package providers
+
+const Mailgun Provider = "mailgun"
+
+func init() {
+	SetInfo(Mailgun, ProviderInfo{
+		AuthType: Basic,
+		BaseURL:  "https://api.mailgun.net",
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+		PostAuthInfoNeeded: false,
+	})
+}
diff --git a/providers/recurly.go b/providers/recurly.go
new file mode 100644
index 00000000..da232c9d
--- /dev/null
+++ b/providers/recurly.go
@@ -0,0 +1,23 @@
+package providers
+
+const Recurly Provider = "recurly"
+
+func init() {
+	SetInfo(Recurly, ProviderInfo{
+		AuthType: Basic,
+		BaseURL:  "https://v3.recurly.com",
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+		PostAuthInfoNeeded: false,
+	})
+}
diff --git a/providers/salesflare.go b/providers/salesflare.go
new file mode 100644
index 00000000..faf1b447
--- /dev/null
+++ b/providers/salesflare.go
@@ -0,0 +1,29 @@
+package providers
+
+const Salesflare Provider = "salesflare"
+
+func init() {
+	// Salesflare configuration
+	SetInfo(Salesflare, ProviderInfo{
+		AuthType: ApiKey,
+		BaseURL:  "https://api.salesflare.com",
+		ApiKeyOpts: &ApiKeyOpts{
+			Type:        InHeader,
+			HeaderName:  "Authorization",
+			ValuePrefix: "Bearer ",
+			DocsURL:     "https://api.salesflare.com/docs#section/Introduction/Authentication",
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	})
+}
diff --git a/providers/stripe.go b/providers/stripe.go
new file mode 100644
index 00000000..40068232
--- /dev/null
+++ b/providers/stripe.go
@@ -0,0 +1,29 @@
+package providers
+
+const Stripe Provider = "stripe"
+
+func init() {
+	// Stripe configuration
+	SetInfo(Stripe, ProviderInfo{
+		AuthType: ApiKey,
+		BaseURL:  "https://api.stripe.com",
+		ApiKeyOpts: &ApiKeyOpts{
+			Type:        InHeader,
+			HeaderName:  "Authorization",
+			ValuePrefix: "Bearer ",
+			DocsURL:     "https://docs.stripe.com/keys",
+		}, Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+		PostAuthInfoNeeded: false,
+	})
+}
diff --git a/providers/types.gen.go b/providers/types.gen.go
index 5c735013..ee968a67 100644
--- a/providers/types.gen.go
+++ b/providers/types.gen.go
@@ -1,6 +1,6 @@
 // Package providers provides primitives to interact with the openapi HTTP API.
 //
-// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.1.0 DO NOT EDIT.
+// Code generated by github.com/deepmap/oapi-codegen version v1.16.3 DO NOT EDIT.
 package providers
 
 // Defines values for ApiKeyOptsType.
diff --git a/providers/types.yaml b/providers/types.yaml
deleted file mode 100644
index c24030cd..00000000
--- a/providers/types.yaml
+++ /dev/null
@@ -1,190 +0,0 @@
-# ==================================
-# DO NOT EDIT THIS FILE unless you are sure of the dependencies as this
-# schema is being referenced in other specs.
-# ==================================
-
-# This file contains the schema for the provider catalog and helps generate
-# the Go types for the schema. The schema is used to create a provider catalog.
-
-openapi: 3.0.0
-
-info:
-  version: 1.0.0
-  title: Provider catalog
-
-components:
-  schemas:
-    AuthType:
-      type: string
-      enum: [oauth2, api_key, basic, none]
-      x-oapi-codegen-extra-tags:
-        validate: required
-
-    Support:
-      type: object
-      x-oapi-codegen-extra-tags:
-        validate: required
-      required:
-        - bulkWrite
-        - proxy
-        - read
-        - subscribe
-        - write
-      properties:
-        bulkWrite:
-          $ref: '#/components/schemas/BulkWriteSupport'
-        proxy:
-          type: boolean
-        read:
-          type: boolean
-        subscribe:
-          type: boolean
-        write:
-          type: boolean
-    BulkWriteSupport:
-      type: object
-      x-oapi-codegen-extra-tags:
-        validate: required
-      required:
-        - insert
-        - update
-        - upsert
-        - delete
-      properties:
-        insert:
-          type: boolean
-        update:
-          type: boolean
-        upsert:
-          type: boolean
-        delete:
-          type: boolean
-    TokenMetadataFields:
-      type: object
-      properties:
-        workspaceRefField:
-          type: string
-          example: account-id
-          x-go-type-skip-optional-pointer: true
-        consumerRefField:
-          type: string
-          example: user-id
-          x-go-type-skip-optional-pointer: true
-        scopesField:
-          type: string
-          example: scopes
-          x-go-type-skip-optional-pointer: true
-
-    Oauth2Opts:
-      type: object
-      required:
-          - grantType
-          - tokenURL
-          - explicitScopesRequired
-          - explicitWorkspaceRequired
-          - tokenMetadataFields
-      properties:
-        grantType:
-          type: string
-          enum: [authorizationCode, clientCredentials, password, PKCE]
-        authURL:
-          type: string
-          example: https://login.salesforce.com/services/oauth2/authorize
-          x-go-type-skip-optional-pointer: true
-        tokenURL:
-          type: string
-          example: https://login.salesforce.com/services/oauth2/token
-          x-oapi-codegen-extra-tags:
-            validate: required
-        explicitScopesRequired:
-          type: boolean
-          example: true
-        explicitWorkspaceRequired:
-          type: boolean
-          example: true
-        audience:
-          type: string
-          example: https://api.mparticle.com
-          x-go-type-skip-optional-pointer: true
-        tokenMetadataFields:
-          $ref: '#/components/schemas/TokenMetadataFields'
-
-    ApiKeyOpts:
-      type: object
-      required:
-        - type
-      properties:
-        type:
-          type: string
-          enum: [in-query, in-header]
-          x-oapi-codegen-extra-tags:
-            validate: required
-        queryParamName:
-          type: string
-          example: api_key
-          x-go-type-skip-optional-pointer: true
-        headerName:
-          type: string
-          example: X-Api-Key
-          x-go-type-skip-optional-pointer: true
-        valuePrefix:
-          type: string
-          example: "Bearer "
-          x-go-type-skip-optional-pointer: true
-        docsURL:
-          type: string
-          description: URL with more information about how to get or use an API key.
-          example: https://docs.example.com/api-key
-          x-go-type-skip-optional-pointer: true
-
-    ProviderOpts:
-        type: object
-        additionalProperties:
-          type: string
-
-    Provider:
-      type: string
-      example: salesforce
-
-    ProviderInfo:
-      type: object
-      required:
-        - name
-        - baseURL
-        - authType
-        - support
-        - providerOpts
-      properties:
-        name:
-          # If you're maintaining catalog.go, the name field will be auto-set for you,
-          # so you don't need to worry about it. Add it for completeness if you want.
-          type: string
-        authType:
-          $ref: '#/components/schemas/AuthType'
-        baseURL:
-          type: string
-          x-oapi-codegen-extra-tags:
-            validate: required
-        oauth2Opts:
-          $ref: '#/components/schemas/Oauth2Opts'
-        apiKeyOpts:
-          $ref: '#/components/schemas/ApiKeyOpts'
-        support:
-          $ref: '#/components/schemas/Support'
-        providerOpts:
-          $ref: '#/components/schemas/ProviderOpts'
-        displayName:
-          type: string
-          example: Zendesk Chat
-          description: The display name of the provider, if omitted, defaults to provider name.
-          x-go-type-skip-optional-pointer: true
-        postAuthInfoNeeded:
-          type: boolean
-          example: true
-          description: If true, we require additional information after auth to start making requests.
-          x-go-type-skip-optional-pointer: true
-
-    CatalogType:
-      type: object
-      additionalProperties:
-        $ref: '#/components/schemas/ProviderInfo'
diff --git a/salesforce/connector.go b/salesforce/connector.go
index 9ba2519b..ae5413c3 100644
--- a/salesforce/connector.go
+++ b/salesforce/connector.go
@@ -34,34 +34,20 @@ func APIVersionSOAP() string {
 
 // NewConnector returns a new Salesforce connector.
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
-	defer func() {
-		if re := recover(); re != nil {
-			tmp, ok := re.(error)
-			if !ok {
-				panic(re)
-			}
-
-			outErr = tmp
-			conn = nil
-		}
-	}()
-
-	params := &sfParams{}
-	for _, opt := range opts {
-		opt(params)
-	}
-
-	var err error
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
 
-	params, err = params.prepare()
+	params, err := parameters{}.FromOptions(opts...)
 	if err != nil {
 		return nil, err
 	}
 
 	// Read provider info & replace catalog variables with given substitutions, if any
-	providerInfo, err := providers.ReadInfo(providers.Salesforce, &map[string]string{
-		"workspace": params.workspace,
-	})
+	substitution := params.Workspace.Substitution()
+
+	providerInfo, err := providers.ReadInfo(providers.Salesforce, &substitution)
 	if err != nil {
 		return nil, err
 	}
@@ -79,7 +65,9 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 	conn = &Connector{
 		BaseURL: restApi,
 		Domain:  domain,
-		Client:  params.client,
+		Client: &common.JSONHTTPClient{
+			HTTPClient: params.Client.Caller,
+		},
 	}
 
 	conn.Client.HTTPClient.Base = providerInfo.BaseURL
diff --git a/salesforce/params.go b/salesforce/params.go
index 36e776b7..1f553510 100644
--- a/salesforce/params.go
+++ b/salesforce/params.go
@@ -2,69 +2,55 @@ package salesforce
 
 import (
 	"context"
+	"errors"
 	"net/http"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"golang.org/x/oauth2"
 )
 
 // Option is a function which mutates the salesforce connector configuration.
-type Option func(params *sfParams)
+type Option func(params *parameters)
 
-// WithClient sets the http client to use for the connector. Saves some boilerplate.
-func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
-	opts ...common.OAuthOption,
-) Option {
-	return func(params *sfParams) {
-		options := []common.OAuthOption{
-			common.WithOAuthClient(client),
-			common.WithOAuthConfig(config),
-			common.WithOAuthToken(token),
-		}
-
-		oauthClient, err := common.NewOAuthHTTPClient(ctx, append(options, opts...)...)
-		if err != nil {
-			panic(err) // caught in NewConnector
-		}
-
-		WithAuthenticatedClient(oauthClient)(params)
-	}
+// parameters is the internal configuration for the salesforce connector.
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Workspace
 }
 
-// WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
-func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *sfParams) {
-		params.client = &common.JSONHTTPClient{
-			HTTPClient: &common.HTTPClient{
-				Client:       client,
-				ErrorHandler: common.InterpretError,
-			},
-		}
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
 	}
-}
 
-// WithWorkspace sets the salesforce workspace to use for the connector. It's required.
-func WithWorkspace(workspaceRef string) Option {
-	return func(params *sfParams) {
-		params.workspace = workspaceRef
-	}
+	return params, params.ValidateParams()
 }
 
-// sfParams is the internal configuration for the salesforce connector.
-type sfParams struct {
-	client    *common.JSONHTTPClient // required
-	workspace string                 // required
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+		p.Workspace.ValidateParams(),
+	)
 }
 
-// prepare finalizes and validates the connector configuration, and returns an error if it's invalid.
-func (p *sfParams) prepare() (out *sfParams, err error) {
-	if p.client == nil {
-		return nil, ErrMissingClient
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
+) Option {
+	return func(params *parameters) {
+		params.WithClient(ctx, client, config, token, opts...)
 	}
+}
 
-	if len(p.workspace) == 0 {
-		return nil, ErrMissingWorkspace
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
 	}
+}
 
-	return p, nil
+func WithWorkspace(workspaceRef string) Option {
+	return func(params *parameters) {
+		params.WithWorkspace(workspaceRef)
+	}
 }
diff --git a/tools/connectorgen/template/base/connector.go.tmpl b/tools/connectorgen/template/base/connector.go.tmpl
index 0a1a2651..a4e1546f 100644
--- a/tools/connectorgen/template/base/connector.go.tmpl
+++ b/tools/connectorgen/template/base/connector.go.tmpl
@@ -6,7 +6,6 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
-	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/providers"
 )


Test patch:
diff --git a/gong/read_test.go b/gong/read_test.go
index e27fad94..2b4b6145 100644
--- a/gong/read_test.go
+++ b/gong/read_test.go
@@ -10,6 +10,8 @@ import (
 	"testing"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/jsonquery"
 	"github.com/amp-labs/connectors/test/utils/mockutils"
 	"github.com/go-test/deep"
 )
@@ -29,6 +31,24 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 		expectedErrs     []error
 		expectedErrTypes []error
 	}{
+		{
+			name: "Mime response header expected",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name: "Incorrect key in payload",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{
+					"garbage": {}
+				}`)
+			})),
+			expectedErrs: []error{jsonquery.ErrKeyNotFound},
+		},
 		{
 			name:  "Bad request handling test",
 			input: common.ReadParams{ObjectName: "calls"},
@@ -43,10 +63,9 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 				}`)
 			})),
 			expectedErrs: []error{
-				errors.New("HTTP status 400: caller error:"), // nolint:goerr113
+				common.ErrBadRequest, errors.New("Failed to verify cursor"), // nolint:goerr113
 			},
 		},
-
 		{
 			name:  "Records section is missing in the payload",
 			input: common.ReadParams{ObjectName: "calls"},
@@ -57,12 +76,11 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 					"value": []
 				}`)
 			})),
-
-			expectedErrs: []error{common.ErrParseError},
+			expectedErrs: []error{jsonquery.ErrKeyNotFound},
 		},
 
 		{
-			name:  "currentPageSize parameter is missing in the payload",
+			name:  "currentPageSize may be missing in payload",
 			input: common.ReadParams{ObjectName: "calls"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.Header().Set("Content-Type", "application/json")
@@ -78,8 +96,11 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 			}		
 					`)
 			})),
-
-			expectedErrs: []error{common.ErrParseError},
+			expected: &common.ReadResult{
+				Data: []common.ReadResultRow{},
+				Done: true,
+			},
+			expectedErrs: nil,
 		},
 
 		{
@@ -150,7 +171,6 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 			},
 			expectedErrs: nil,
 		},
-
 		{
 			name:  "Incorrect data type in payload",
 			input: common.ReadParams{ObjectName: "calls"},
@@ -158,10 +178,10 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 				w.Header().Set("Content-Type", "application/json")
 				w.WriteHeader(http.StatusOK)
 				writeBody(w, `{
-					"values": {}
+					"calls": {}
 				}`)
 			})),
-			expectedErrs: []error{common.ErrParseError},
+			expectedErrs: []error{jsonquery.ErrNotArray},
 		},
 	}
 
diff --git a/test/gong/connector.go b/test/gong/connector.go
new file mode 100644
index 00000000..fdf19a83
--- /dev/null
+++ b/test/gong/connector.go
@@ -0,0 +1,55 @@
+package gong
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/gong"
+	testUtils "github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/utils"
+)
+
+func GetGongConnector(ctx context.Context, filePath string) *gong.Connector {
+	registry := utils.NewCredentialsRegistry()
+
+	readers := []utils.Reader{
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.clientId",
+			CredKey:  utils.ClientId,
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.clientSecret",
+			CredKey:  utils.ClientSecret,
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.accessToken",
+			CredKey:  utils.AccessToken,
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.refreshToken",
+			CredKey:  utils.RefreshToken,
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.provider",
+			CredKey:  utils.Provider,
+		},
+	}
+	_ = registry.AddReaders(readers...)
+
+	cfg := utils.GongOAuthConfigFromRegistry(registry)
+	tok := utils.GongOauthTokenFromRegistry(registry)
+
+	conn, err := gong.NewConnector(
+		gong.WithClient(ctx, http.DefaultClient, cfg, tok),
+	)
+	if err != nil {
+		testUtils.Fail("error creating connector", "error", err)
+	}
+
+	return conn
+}
diff --git a/test/gong/read/read.go b/test/gong/read/read.go
index f7db4417..9faa3fac 100644
--- a/test/gong/read/read.go
+++ b/test/gong/read/read.go
@@ -4,12 +4,10 @@ import (
 	"context"
 	"encoding/json"
 	"log/slog"
-	"net/http"
 	"os"
 
 	"github.com/amp-labs/connectors"
-	"github.com/amp-labs/connectors/gong"
-	"github.com/amp-labs/connectors/utils"
+	connTest "github.com/amp-labs/connectors/test/gong"
 	"github.com/joho/godotenv"
 )
 
@@ -17,55 +15,6 @@ const (
 	DefaultCredsFile = "creds.json"
 )
 
-func GetGongConnector(ctx context.Context, filePath string) *gong.Connector {
-	registry := utils.NewCredentialsRegistry()
-
-	readers := []utils.Reader{
-		&utils.JSONReader{
-			FilePath: filePath,
-			JSONPath: "$['clientId']",
-			CredKey:  "clientId",
-		},
-		&utils.JSONReader{
-			FilePath: filePath,
-			JSONPath: "$['clientSecret']",
-			CredKey:  "clientSecret",
-		},
-		&utils.JSONReader{
-			FilePath: filePath,
-			JSONPath: "$['refreshToken']",
-			CredKey:  "refreshToken",
-		},
-		&utils.JSONReader{
-			FilePath: filePath,
-			JSONPath: "$['accessToken']",
-			CredKey:  "accessToken",
-		},
-		&utils.JSONReader{
-			FilePath: filePath,
-			JSONPath: "$['provider']",
-			CredKey:  "provider",
-		},
-	}
-	registry.AddReaders(readers...)
-
-	cfg := utils.GongOAuthConfigFromRegistry(registry)
-	tok := utils.GongOauthTokenFromRegistry(registry)
-
-	conn, err := gong.NewConnector(
-		gong.WithClient(ctx, http.DefaultClient, cfg, tok),
-	)
-	if err != nil {
-		slog.Error("error creating gong connector", "error", err)
-	}
-
-	defer func() {
-		_ = conn.Close()
-	}()
-
-	return conn
-}
-
 func main() {
 	os.Exit(mainFn())
 }
@@ -82,14 +31,14 @@ func mainFn() int {
 		slog.Warn("No .env file provided")
 	}
 
-	gong := GetGongConnector(context.Background(), DefaultCredsFile)
+	conn := connTest.GetGongConnector(context.Background(), DefaultCredsFile)
 
 	config := connectors.ReadParams{
 		ObjectName: "calls", // could be calls, users
 		Fields:     []string{"url"},
 	}
 
-	result, err := gong.Read(context.Background(), config)
+	result, err := conn.Read(context.Background(), config)
 	if err != nil {
 		slog.Error("Error reading from Gong", "error", err)
 		return 1
diff --git a/tools/connectorgen/template/test/connector.go.tmpl b/tools/connectorgen/template/test/connector.go.tmpl
index ee5e8193..7feac7f5 100644
--- a/tools/connectorgen/template/test/connector.go.tmpl
+++ b/tools/connectorgen/template/test/connector.go.tmpl
@@ -4,7 +4,6 @@ import (
 	"context"
 	"net/http"
 
-	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/{{ .Package }}"
 	testUtils "github.com/amp-labs/connectors/test/utils"
 	"github.com/amp-labs/connectors/utils"
@@ -16,32 +15,32 @@ func Get{{ .Provider }}Connector(ctx context.Context, filePath string) *{{ .Pack
 	readers := []utils.Reader{
 		&utils.JSONReader{
 			FilePath: filePath,
-			JSONPath: "$.CLIENT_ID",
+			JSONPath: "$.clientId",
 			CredKey:  utils.ClientId,
 		},
 		&utils.JSONReader{
 			FilePath: filePath,
-			JSONPath: "$.CLIENT_SECRET",
+			JSONPath: "$.clientSecret",
 			CredKey:  utils.ClientSecret,
 		},
 		&utils.JSONReader{
 			FilePath: filePath,
-			JSONPath: "$.ACCESS_TOKEN",
+			JSONPath: "$.accessToken",
 			CredKey:  utils.AccessToken,
 		},
 		&utils.JSONReader{
 			FilePath: filePath,
-			JSONPath: "$.REFRESH_TOKEN",
+			JSONPath: "$.refreshToken",
 			CredKey:  utils.RefreshToken,
 		},
 		&utils.JSONReader{
 			FilePath: filePath,
-			JSONPath: "$.PROVIDER",
+			JSONPath: "$.provider",
 			CredKey:  utils.Provider,
 		},
 		&utils.JSONReader{
 			FilePath: filePath,
-			JSONPath: "$.WORKSPACE",
+			JSONPath: "$.workspace",
 			CredKey:  utils.WorkspaceRef,
 		},
 	}
@@ -53,7 +52,7 @@ func Get{{ .Provider }}Connector(ctx context.Context, filePath string) *{{ .Pack
 	workspace := registry.MustString(utils.WorkspaceRef)
 
 	// TODO provide required options
-	conn, err := connectors.{{ .Provider }}(
+	conn, err := {{ .Package }}.NewConnector(
 		{{ .Package }}.WithClient(ctx, http.DefaultClient, cfg, tok),
 		{{ .Package }}.WithWorkspace(workspace),
 	)