Repository: amp-labs/connectors
Pull request #199 (2024-04-10 03:47:07)

Sellsy grant type_fix
Rajat, as discussed, here is the PKCE grant type addition for Sellsy


Patch:
diff --git a/common/error.go b/common/error.go
index c9f8f18b..4ba45cd3 100644
--- a/common/error.go
+++ b/common/error.go
@@ -31,3 +31,14 @@ func InterpretError(res *http.Response, body []byte) error {
 
 	return NewHTTPStatusError(res.StatusCode, fmt.Errorf("%w: %s", ErrUnknown, string(body)))
 }
+
+func PanicRecovery(wrapup func(cause error)) {
+	if re := recover(); re != nil {
+		err, ok := re.(error)
+		if !ok {
+			panic(re)
+		}
+
+		wrapup(err)
+	}
+}
diff --git a/common/interpreter/error.go b/common/interpreter/error.go
new file mode 100644
index 00000000..a54f94b8
--- /dev/null
+++ b/common/interpreter/error.go
@@ -0,0 +1,37 @@
+package interpreter
+
+import (
+	"errors"
+	"fmt"
+	"mime"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+var (
+	ErrUnmarshal          = errors.New("unmarshal failed")
+	ErrMissingContentType = errors.New("mime.ParseMediaType failed")
+)
+
+// FaultyResponseHandler used to parse erroneous response.
+type FaultyResponseHandler func(res *http.Response, body []byte) error
+
+// ErrorHandler invokes a function that matches response media type with parse error, ex: JSON<->JsonParserMethod
+// otherwise defaults to general error interpretation.
+type ErrorHandler struct {
+	JSON FaultyResponseHandler
+}
+
+func (h ErrorHandler) Handle(res *http.Response, body []byte) error {
+	mediaType, _, err := mime.ParseMediaType(res.Header.Get("Content-Type"))
+	if err != nil {
+		return fmt.Errorf("%w: %w", ErrMissingContentType, err)
+	}
+
+	if h.JSON != nil && mediaType == "application/json" {
+		return h.JSON(res, body)
+	}
+
+	return common.InterpretError(res, body)
+}
diff --git a/common/json.go b/common/json.go
index b8e19239..4371eb50 100644
--- a/common/json.go
+++ b/common/json.go
@@ -60,11 +60,6 @@ func (j *JSONHTTPClient) Post(ctx context.Context,
 		return nil, err
 	}
 
-	// empty response body should not be parsed as JSON since it will cause ajson to err
-	if len(body) == 0 {
-		return nil, nil //nolint:nilnil
-	}
-
 	return parseJSONResponse(res, body)
 }
 
@@ -76,9 +71,13 @@ func (j *JSONHTTPClient) Patch(ctx context.Context,
 		return nil, err
 	}
 
-	// empty response body should not be parsed as JSON since it will cause ajson to err
-	if len(body) == 0 {
-		return nil, nil //nolint:nilnil
+	return parseJSONResponse(res, body)
+}
+
+func (j *JSONHTTPClient) Delete(ctx context.Context, url string, headers ...Header) (*JSONHTTPResponse, error) {
+	res, body, err := j.HTTPClient.Delete(ctx, url, addAcceptJSONHeader(headers)) //nolint:bodyclose
+	if err != nil {
+		return nil, err
 	}
 
 	return parseJSONResponse(res, body)
@@ -86,6 +85,10 @@ func (j *JSONHTTPClient) Patch(ctx context.Context,
 
 // parseJSONResponse parses the given HTTP response and returns a JSONHTTPResponse.
 func parseJSONResponse(res *http.Response, body []byte) (*JSONHTTPResponse, error) {
+	// empty response body should not be parsed as JSON since it will cause ajson to err
+	if len(body) == 0 {
+		return nil, nil //nolint:nilnil
+	}
 	// Ensure the response is JSON
 	ct := res.Header.Get("Content-Type")
 	if len(ct) > 0 {
diff --git a/common/paramsbuilder/params.go b/common/paramsbuilder/params.go
new file mode 100644
index 00000000..c14849d7
--- /dev/null
+++ b/common/paramsbuilder/params.go
@@ -0,0 +1,104 @@
+package paramsbuilder
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"golang.org/x/oauth2"
+)
+
+// ParamAssurance checks that param data is valid
+// Every param instance must implement it.
+type ParamAssurance interface {
+	ValidateParams() error
+}
+
+var (
+	ErrMissingClient    = errors.New("JSON http client not set")
+	ErrMissingWorkspace = errors.New("missing workspace name")
+)
+
+// Client params sets up authenticated proxy HTTP client
+// This can be reused among other param builders by composition.
+type Client struct {
+	Caller *common.JSONHTTPClient
+}
+
+func (p *Client) ValidateParams() error {
+	if p.Caller == nil {
+		return ErrMissingClient
+	}
+
+	return nil
+}
+
+func (p *Client) WithClient(
+	ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token,
+	opts ...common.OAuthOption,
+) {
+	options := []common.OAuthOption{
+		common.WithClient(client),
+		common.WithOAuthConfig(config),
+		common.WithOAuthToken(token),
+	}
+
+	oauthClient, err := common.NewOAuthHTTPClient(ctx, append(options, opts...)...)
+	if err != nil {
+		panic(err) // caught in NewConnector
+	}
+
+	p.WithAuthenticatedClient(oauthClient)
+}
+
+func (p *Client) WithAuthenticatedClient(client common.AuthenticatedHTTPClient) {
+	p.Caller = &common.JSONHTTPClient{
+		HTTPClient: &common.HTTPClient{
+			Client:       client,
+			ErrorHandler: common.InterpretError,
+		},
+	}
+}
+
+// Workspace params sets up varying workspace name.
+type Workspace struct {
+	Name string
+}
+
+func (p *Workspace) ValidateParams() error {
+	if len(p.Name) == 0 {
+		return ErrMissingWorkspace
+	}
+
+	return nil
+}
+
+func (p *Workspace) WithWorkspace(workspaceRef string) {
+	p.Name = workspaceRef
+}
+
+// Module params adds suffix to URL controlling API versions.
+type Module struct {
+	Suffix string
+}
+
+func (p *Module) ValidateParams() error {
+	// url suffix may be omitted
+	return nil
+}
+
+func (p *Module) WithModule(module APIModule) {
+	p.Suffix = module.String()
+}
+
+type APIModule struct {
+	Label   string // e.g. "crm"
+	Version string // e.g. "v3"
+}
+
+func (a APIModule) String() string {
+	return fmt.Sprintf("%s/%s", a.Label, a.Version)
+}
diff --git a/common/parse.go b/common/parse.go
index a8a58218..b24dd65e 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -1,6 +1,8 @@
 package common
 
 import (
+	"errors"
+	"math"
 	"strings"
 
 	"github.com/spyzhov/ajson"
@@ -73,3 +75,105 @@ func ExtractLowercaseFieldsFromRaw(fields []string, record map[string]interface{
 
 	return out
 }
+
+var (
+	ErrNotArray   = errors.New("JSON value is not an array")
+	ErrNotObject  = errors.New("JSON value is not an object")
+	ErrNotString  = errors.New("JSON value is not a string")
+	ErrNotNumeric = errors.New("JSON value is not a numeric")
+	ErrNotInteger = errors.New("JSON value is not an integer")
+
+	// JSONManager is a helpful wrapper of ajson library that adds errors when querying JSON payload
+	// and provides common conversion methods.
+	JSONManager = jsonManager{} //nolint:gochecknoglobals
+)
+
+type jsonManager struct{}
+
+func (jsonManager) ArrToMap(arr []*ajson.Node) ([]map[string]any, error) {
+	output := make([]map[string]any, 0, len(arr))
+
+	for _, v := range arr {
+		if !v.IsObject() {
+			return nil, ErrNotObject
+		}
+
+		data, err := v.Unpack()
+		if err != nil {
+			return nil, err
+		}
+
+		m, ok := data.(map[string]interface{})
+		if !ok {
+			return nil, ErrNotObject
+		}
+
+		output = append(output, m)
+	}
+
+	return output, nil
+}
+
+func (jsonManager) GetInteger(node *ajson.Node, key string) (int64, error) {
+	innerNode, err := node.GetKey(key)
+	if err != nil {
+		return 0, err
+	}
+
+	count, err := innerNode.GetNumeric()
+	if err != nil {
+		return 0, ErrNotNumeric
+	}
+
+	if math.Mod(count, 1.0) != 0 {
+		return 0, ErrNotInteger
+	}
+
+	return int64(count), nil
+}
+
+func (jsonManager) GetArr(node *ajson.Node, key string) ([]*ajson.Node, error) {
+	records, err := node.GetKey(key)
+	if err != nil {
+		return nil, err
+	}
+
+	arr, err := records.GetArray()
+	if err != nil {
+		return nil, ErrNotArray
+	}
+
+	return arr, nil
+}
+
+func (jsonManager) ArrSize(node *ajson.Node, key string) (int64, error) {
+	innerNode, err := node.GetKey(key)
+	if err != nil {
+		return 0, err
+	}
+
+	if !innerNode.IsArray() {
+		return 0, ErrNotArray
+	}
+
+	return int64(innerNode.Size()), nil
+}
+
+func (jsonManager) GetString(node *ajson.Node, key string, optional bool) (string, error) {
+	if optional && !node.HasKey(key) {
+		// null value in payload is allowed
+		return "", nil
+	}
+
+	innerNode, err := node.GetKey(key)
+	if err != nil {
+		return "", err
+	}
+
+	txt, err := innerNode.GetString()
+	if err != nil {
+		return "", ErrNotString
+	}
+
+	return txt, nil
+}
diff --git a/common/types.go b/common/types.go
index cba27e31..ed88ac47 100644
--- a/common/types.go
+++ b/common/types.go
@@ -81,7 +81,7 @@ type ReadParams struct {
 	// The fields we are reading from the object, e.g. ["Id", "Name", "BillingCity"]
 	Fields []string // required, at least one field needed
 	// NextPage is an opaque token that can be used to get the next page of results.
-	NextPage string // optional, only set this if you want to read the next page of results
+	NextPage NextPageToken // optional, only set this if you want to read the next page of results
 	// Since is a timestamp that can be used to get only records that have changed since that time.
 	Since time.Time // optional, omit this to fetch all records
 	// Deleted is true if we want to read deleted records instead of active records.
@@ -107,6 +107,10 @@ type WriteParams struct {
 // will expire after some period of time. So long-term storage of this token is not recommended.
 type NextPageToken string
 
+func (t NextPageToken) String() string {
+	return string(t)
+}
+
 // ReadResult is what's returned from reading data via the Read call.
 type ReadResult struct {
 	// Rows is the number of total rows in the result.
diff --git a/basic/client.go b/connector/client.go
similarity index 65%
rename from basic/client.go
rename to connector/client.go
index 4bab3a3a..6aaee506 100644
--- a/basic/client.go
+++ b/connector/client.go
@@ -1,9 +1,13 @@
-package basic
+package connector
 
 import (
 	"github.com/amp-labs/connectors/common"
 )
 
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.Client
+}
+
 func (c *Connector) HTTPClient() *common.HTTPClient {
 	return c.Client.HTTPClient
 }
diff --git a/basic/connector.go b/connector/connector.go
similarity index 90%
rename from basic/connector.go
rename to connector/connector.go
index fd70bbf6..31a181ca 100644
--- a/basic/connector.go
+++ b/connector/connector.go
@@ -1,18 +1,16 @@
-package basic
+package connector
 
 import (
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/providers"
 )
 
-// Connector is a Basic connector.
 type Connector struct {
 	ProviderInfo *providers.ProviderInfo
 	Client       *common.JSONHTTPClient
 	provider     providers.Provider
 }
 
-// NewConnector returns a new Basic connector.
 func NewConnector(
 	provider providers.Provider,
 	opts ...Option,
@@ -30,7 +28,7 @@ func NewConnector(
 	}()
 
 	// Set up basic params
-	params := &basicParams{}
+	params := &connectorParams{}
 	params.provider = provider
 
 	// Apply options & verify
diff --git a/basic/errors.go b/connector/errors.go
similarity index 91%
rename from basic/errors.go
rename to connector/errors.go
index b14c12ae..411efe71 100644
--- a/basic/errors.go
+++ b/connector/errors.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 import (
 	"net/http"
diff --git a/basic/params.go b/connector/params.go
similarity index 87%
rename from basic/params.go
rename to connector/params.go
index c0b9ac1e..94a31209 100644
--- a/basic/params.go
+++ b/connector/params.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 import (
 	"context"
@@ -18,15 +18,15 @@ var (
 	ErrMissingProvider = errors.New("missing provider")
 )
 
-type Option func(*basicParams)
+type Option func(*connectorParams)
 
-type basicParams struct {
+type connectorParams struct {
 	provider      providers.Provider
 	client        *common.JSONHTTPClient
 	substitutions map[string]string
 }
 
-func (p *basicParams) prepare() (*basicParams, error) {
+func (p *connectorParams) prepare() (*connectorParams, error) {
 	if p.provider == "" {
 		return nil, ErrMissingProvider
 	}
@@ -42,7 +42,7 @@ func (p *basicParams) prepare() (*basicParams, error) {
 // 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 *basicParams) {
+	return func(params *connectorParams) {
 		params.substitutions = substitutions
 	}
 }
@@ -51,7 +51,7 @@ func WithCatalogSubstitutions(substitutions map[string]string) Option {
 func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config, token *oauth2.Token,
 	opts ...common.OAuthOption,
 ) Option {
-	return func(params *basicParams) {
+	return func(params *connectorParams) {
 		options := []common.OAuthOption{
 			common.WithClient(client),
 			common.WithOAuthConfig(config),
@@ -69,7 +69,7 @@ func WithClient(ctx context.Context, client *http.Client, config *oauth2.Config,
 
 // WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
 func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
-	return func(params *basicParams) {
+	return func(params *connectorParams) {
 		params.client = &common.JSONHTTPClient{
 			HTTPClient: &common.HTTPClient{
 				Client:       client,
diff --git a/basic/provider.go b/connector/provider.go
similarity index 87%
rename from basic/provider.go
rename to connector/provider.go
index 6f33a72d..f32e5eb4 100644
--- a/basic/provider.go
+++ b/connector/provider.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 import (
 	"github.com/amp-labs/connectors/providers"
diff --git a/basic/string.go b/connector/string.go
similarity index 81%
rename from basic/string.go
rename to connector/string.go
index e38aad4e..d1c56325 100644
--- a/basic/string.go
+++ b/connector/string.go
@@ -1,4 +1,4 @@
-package basic
+package connector
 
 func (c *Connector) String() string {
 	return c.Provider() + ".Connector"
diff --git a/connectors.go b/connectors.go
index c730df24..856a353e 100644
--- a/connectors.go
+++ b/connectors.go
@@ -5,19 +5,27 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"net/http"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/hubspot"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	"github.com/amp-labs/connectors/mock"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/salesforce"
 )
 
-// BasicConnector is an interface that can be used to implement a connector with
+// Connector is an interface that can be used to implement a connector with
 // basic configuration about the provider.
-type BasicConnector interface {
+type Connector interface {
 	fmt.Stringer
 	io.Closer
 
+	// JSONHTTPClient returns the underlying JSON HTTP client. This is useful for
+	// testing, or for calling methods that aren't exposed by the Connector
+	// interface directly. Authentication and token refreshes will be handled automatically.
+	JSONHTTPClient() *common.JSONHTTPClient
+
 	// HTTPClient returns the underlying HTTP client. This is useful for proxy requests.
 	HTTPClient() *common.HTTPClient
 
@@ -25,9 +33,9 @@ type BasicConnector interface {
 	Provider() providers.Provider
 }
 
-// Connector is an interface that all connectors must implement.
-type Connector interface {
-	BasicConnector
+// ReadConnector is an interface that extends the Connector interface with read capabilities.
+type ReadConnector interface {
+	Connector
 
 	// Read reads a page of data from the connector. This can be called multiple
 	// times to read all the data. The caller is responsible for paging, by
@@ -36,15 +44,21 @@ type Connector interface {
 	// Authentication corner cases are handled internally, but all other errors
 	// are returned to the caller.
 	Read(ctx context.Context, params ReadParams) (*ReadResult, error)
+}
+
+// WriteConnector is an interface that extends the Connector interface with write capabilities.
+type WriteConnector interface {
+	Connector
 
 	Write(ctx context.Context, params WriteParams) (*WriteResult, error)
+}
 
-	ListObjectMetadata(ctx context.Context, objectNames []string) (*ListObjectMetadataResult, error)
+// ObjectMetadataConnector is an interface that extends the Connector interface with
+// the ability to list object metadata.
+type ObjectMetadataConnector interface {
+	Connector
 
-	// JSONHTTPClient returns the underlying JSON HTTP client. This is useful for
-	// testing, or for calling methods that aren't exposed by the Connector
-	// interface directly. Authentication and token refreshes will be handled automatically.
-	JSONHTTPClient() *common.JSONHTTPClient
+	ListObjectMetadata(ctx context.Context, objectNames []string) (*ListObjectMetadataResult, error)
 }
 
 // API is a function that returns a Connector. It's used as a factory.
@@ -65,6 +79,12 @@ var Salesforce API[*salesforce.Connector, salesforce.Option] = salesforce.NewCon
 // Hubspot is an API that returns a new Hubspot Connector.
 var Hubspot API[*hubspot.Connector, hubspot.Option] = hubspot.NewConnector //nolint:gochecknoglobals
 
+// MSDynamicsSales is an API that returns a new MS Dynamics 365 Sales Connector.
+var MSDynamicsSales API[*microsoftdynamicscrm.Connector, microsoftdynamicscrm.Option] = microsoftdynamicscrm.NewConnector //nolint:gochecknoglobals,lll
+
+// Mock is an API that returns a new Mock Connector.
+var Mock API[*mock.Connector, mock.Option] = mock.NewConnector //nolint:gochecknoglobals
+
 // We re-export the following types so that they can be used by consumers of this library.
 type (
 	ReadParams               = common.ReadParams
@@ -103,6 +123,8 @@ var (
 // since you get type safety and more readable code.
 func New(provider providers.Provider, opts map[string]any) (Connector, error) { //nolint:ireturn
 	switch provider {
+	case providers.Mock:
+		return newMock(opts)
 	case providers.Salesforce:
 		return newSalesforce(opts)
 	case providers.Hubspot:
@@ -112,6 +134,39 @@ func New(provider providers.Provider, opts map[string]any) (Connector, error) {
 	}
 }
 
+// newMock returns a new Mock Connector, by unwrapping the options and passing them to the Mock API.
+func newMock(opts map[string]any) (Connector, error) { //nolint:ireturn
+	var options []mock.Option
+
+	c, valid := getParam[*http.Client](opts, "client")
+	if valid {
+		options = append(options, mock.WithClient(c))
+	}
+
+	a, valid := getParam[common.AuthenticatedHTTPClient](opts, "authenticated-client")
+	if valid {
+		options = append(options, mock.WithAuthenticatedClient(a))
+	}
+
+	r, valid := getParam[func(ctx context.Context, params ReadParams) (*ReadResult, error)](opts, "read")
+	if valid {
+		options = append(options, mock.WithRead(r))
+	}
+
+	w, valid := getParam[func(ctx context.Context, params WriteParams) (*WriteResult, error)](opts, "write")
+	if valid {
+		options = append(options, mock.WithWrite(w))
+	}
+
+	l, valid := getParam[func(ctx context.Context, objectNames []string) (*ListObjectMetadataResult, error)](
+		opts, "list-object-metadata")
+	if valid {
+		options = append(options, mock.WithListObjectMetadata(l))
+	}
+
+	return Mock.New(options...)
+}
+
 // newSalesforce returns a new Salesforce Connector, by unwrapping the options and passing them to the Salesforce API.
 func newSalesforce(opts map[string]any) (Connector, error) { //nolint:ireturn
 	var options []salesforce.Option
diff --git a/go.mod b/go.mod
index 7ec225df..63926f5f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.21
 require (
 	github.com/brianvoe/gofakeit/v6 v6.28.0
 	github.com/go-playground/validator v9.31.0+incompatible
+	github.com/go-test/deep v1.1.0
 	github.com/joho/godotenv v1.5.1
 	github.com/spyzhov/ajson v0.9.1
 	github.com/stretchr/testify v1.9.0
diff --git a/go.sum b/go.sum
index b0499960..b5b1cfb6 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA=
 github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig=
+github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
+github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
diff --git a/hubspot/read.go b/hubspot/read.go
index 58ac90f1..9538e2a1 100644
--- a/hubspot/read.go
+++ b/hubspot/read.go
@@ -49,7 +49,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	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.
-		rsp, err = c.get(ctx, config.NextPage)
+		rsp, err = c.get(ctx, config.NextPage.String())
 	} else {
 		// If NextPage is not set, then we're reading the first page of results.
 		// We need to construct the query and then make the request.
diff --git a/hubspot/types.go b/hubspot/types.go
index 5827ca62..a326a25a 100644
--- a/hubspot/types.go
+++ b/hubspot/types.go
@@ -1,10 +1,12 @@
 package hubspot
 
+import "github.com/amp-labs/connectors/common"
+
 type SearchParams struct {
 	// The name of the object we are reading, e.g. "Account"
 	ObjectName string // required
 	// NextPage is an opaque token that can be used to get the next page of results.
-	NextPage string // optional, only set this if you want to read the next page of results
+	NextPage common.NextPageToken // optional, only set this if you want to read the next page of results
 	// SortBy is the field to sort by in the direction specified by SortDirection.
 	SortBy []SortBy // optional
 	// FilterBy is the filter to apply to the search
diff --git a/microsoftdynamicscrm/client.go b/microsoftdynamicscrm/client.go
new file mode 100644
index 00000000..e35e0e97
--- /dev/null
+++ b/microsoftdynamicscrm/client.go
@@ -0,0 +1,16 @@
+package microsoftdynamicscrm
+
+import "github.com/amp-labs/connectors/common"
+
+// JSONHTTPClient returns the underlying JSON HTTP client.
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.Client
+}
+
+func (c *Connector) HTTPClient() *common.HTTPClient {
+	return c.Client.HTTPClient
+}
+
+func (c *Connector) Close() error {
+	return nil
+}
diff --git a/microsoftdynamicscrm/connector.go b/microsoftdynamicscrm/connector.go
new file mode 100644
index 00000000..32abf6a8
--- /dev/null
+++ b/microsoftdynamicscrm/connector.go
@@ -0,0 +1,79 @@
+package microsoftdynamicscrm
+
+import (
+	"fmt"
+	"strings"
+
+	"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/providers"
+)
+
+var DefaultModuleCRM = paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	Label:   "api/data",
+	Version: "v9.2",
+}
+
+type Connector struct {
+	BaseURL string
+	Module  string
+	Client  *common.JSONHTTPClient
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
+
+	params, err := parameters{}.FromOptions(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	providerInfo, err := providers.ReadInfo(providers.MicrosoftDynamics365CRM, &map[string]string{
+		"workspace": params.Workspace.Name,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	conn = &Connector{
+		Module: params.Module.Suffix,
+		Client: params.Client.Caller,
+	}
+	// connector and its client must mirror base url and provide its own error parser
+	conn.setBaseURL(providerInfo.BaseURL)
+	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
+		JSON: conn.interpretJSONError,
+	}.Handle
+
+	return conn, nil
+}
+
+func (c *Connector) Provider() providers.Provider {
+	return providers.MicrosoftDynamics365CRM
+}
+
+func (c *Connector) String() string {
+	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Module)
+}
+
+func (c *Connector) getURL(arg string) string {
+	parts := []string{c.BaseURL, c.Module, arg}
+	filtered := make([]string, 0)
+
+	for _, part := range parts {
+		if len(part) != 0 {
+			filtered = append(filtered, part)
+		}
+	}
+
+	return strings.Join(filtered, "/")
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/microsoftdynamicscrm/errors.go b/microsoftdynamicscrm/errors.go
new file mode 100644
index 00000000..bdfaca42
--- /dev/null
+++ b/microsoftdynamicscrm/errors.go
@@ -0,0 +1,66 @@
+package microsoftdynamicscrm
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //nolint:cyclop
+	apiError := &SalesResponseError{}
+	if err := json.Unmarshal(body, &apiError); err != nil {
+		return fmt.Errorf("interpretJSONError: %w %w", interpreter.ErrUnmarshal, err)
+	}
+
+	switch res.StatusCode {
+	case http.StatusBadRequest:
+		return createError(common.ErrBadRequest, apiError)
+	case http.StatusUnauthorized:
+		return createError(common.ErrAccessToken, apiError)
+	case http.StatusForbidden:
+		return createError(common.ErrForbidden, apiError)
+	case http.StatusNotFound:
+		return createError(common.ErrBadRequest, apiError) // TODO more specific error
+	case http.StatusMethodNotAllowed:
+		return createError(common.ErrBadRequest, apiError) // TODO more specific error
+	case http.StatusPreconditionFailed:
+		return createError(common.ErrBadRequest, apiError) // TODO more specific error
+	case http.StatusRequestEntityTooLarge:
+		return createError(common.ErrBadRequest, apiError) // TODO more specific error
+	case http.StatusTooManyRequests:
+		return createError(common.ErrLimitExceeded, apiError)
+	case http.StatusNotImplemented:
+		return createError(common.ErrNotImplemented, apiError)
+	case http.StatusServiceUnavailable:
+		return createError(common.ErrServer, apiError)
+	default:
+		return common.InterpretError(res, body)
+	}
+}
+
+type SalesResponseError struct {
+	Err SalesError `json:"error"`
+}
+
+type SalesError struct {
+	Code    string `json:"code"`
+	Message string `json:"message"`
+	*EnhancedSalesError
+}
+
+type EnhancedSalesError struct {
+	HelpLink     string `json:"@Microsoft.PowerApps.CDS.HelpLink"`           // nolint:tagliatelle
+	TraceText    string `json:"@Microsoft.PowerApps.CDS.TraceText"`          // nolint:tagliatelle
+	InnerMessage string `json:"@Microsoft.PowerApps.CDS.InnerError.Message"` // nolint:tagliatelle
+}
+
+func createError(base error, response *SalesResponseError) error {
+	if len(response.Err.Message) > 0 {
+		return fmt.Errorf("%w: %s", base, response.Err.Message)
+	}
+
+	return base
+}
diff --git a/microsoftdynamicscrm/metadata.go b/microsoftdynamicscrm/metadata.go
new file mode 100644
index 00000000..89e4ced3
--- /dev/null
+++ b/microsoftdynamicscrm/metadata.go
@@ -0,0 +1,14 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) ListObjectMetadata(
+	ctx context.Context, objectNames []string,
+) (*common.ListObjectMetadataResult, error) {
+	// TODO implement me
+	panic("implement me")
+}
diff --git a/microsoftdynamicscrm/methods.go b/microsoftdynamicscrm/methods.go
new file mode 100644
index 00000000..72d1889d
--- /dev/null
+++ b/microsoftdynamicscrm/methods.go
@@ -0,0 +1,43 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// TODO these methods look repetitive.
+func (c *Connector) get(ctx context.Context,
+	url string, headers ...common.Header,
+) (*common.JSONHTTPResponse, error) {
+	rsp, err := c.Client.Get(ctx, url, headers...)
+	if err = handleError(err); err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func handleError(err error) error {
+	if err == nil {
+		return nil
+	}
+
+	switch {
+	case errors.Is(err, common.ErrAccessToken):
+		slog.Warn("Access token invalid, retrying", "error", err)
+
+		fallthrough
+	case errors.Is(err, common.ErrRetryable):
+		fallthrough
+	case errors.Is(err, common.ErrApiDisabled):
+		fallthrough
+	case errors.Is(err, common.ErrForbidden):
+		fallthrough
+	default:
+		// Anything else is a permanent error
+		return err
+	}
+}
diff --git a/microsoftdynamicscrm/params.go b/microsoftdynamicscrm/params.go
new file mode 100644
index 00000000..7d0ab030
--- /dev/null
+++ b/microsoftdynamicscrm/params.go
@@ -0,0 +1,69 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"golang.org/x/oauth2"
+)
+
+const (
+	// DefaultPageSize is number of elements per page.
+	DefaultPageSize = 100
+)
+
+// Option is a function which mutates the connector configuration.
+type Option func(params *parameters)
+
+// parameters MS Sales supports auth client, workspace, etc. by delegation.
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Workspace
+	paramsbuilder.Module
+}
+
+func (p parameters) FromOptions(opts ...Option) (*parameters, error) {
+	params := &p
+	for _, opt := range opts {
+		opt(params)
+	}
+
+	return params, params.ValidateParams()
+}
+
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+		p.Workspace.ValidateParams(),
+		p.Module.ValidateParams(),
+	)
+}
+
+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...)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
+
+func WithWorkspace(workspaceRef string) Option {
+	return func(params *parameters) {
+		params.WithWorkspace(workspaceRef)
+	}
+}
+
+func WithModule(module paramsbuilder.APIModule) Option {
+	return func(params *parameters) {
+		params.WithModule(module)
+	}
+}
diff --git a/microsoftdynamicscrm/parse.go b/microsoftdynamicscrm/parse.go
new file mode 100644
index 00000000..0d90d848
--- /dev/null
+++ b/microsoftdynamicscrm/parse.go
@@ -0,0 +1,36 @@
+package microsoftdynamicscrm
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/spyzhov/ajson"
+)
+
+func getTotalSize(node *ajson.Node) (int64, error) {
+	return common.JSONManager.ArrSize(node, "value")
+}
+
+func getRecords(node *ajson.Node) ([]map[string]any, error) {
+	arr, err := common.JSONManager.GetArr(node, "value")
+	if err != nil {
+		return nil, err
+	}
+
+	return common.JSONManager.ArrToMap(arr)
+}
+
+func getNextRecordsURL(node *ajson.Node) (string, error) {
+	return common.JSONManager.GetString(node, "@odata.nextLink", true)
+}
+
+func getMarshaledData(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
+	data := make([]common.ReadResultRow, len(records))
+
+	for i, record := range records {
+		data[i] = common.ReadResultRow{
+			Fields: common.ExtractLowercaseFieldsFromRaw(fields, record),
+			Raw:    record,
+		}
+	}
+
+	return data, nil
+}
diff --git a/microsoftdynamicscrm/read.go b/microsoftdynamicscrm/read.go
new file mode 100644
index 00000000..7d989533
--- /dev/null
+++ b/microsoftdynamicscrm/read.go
@@ -0,0 +1,79 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// nolint:lll
+// Microsoft API supports other capabilities like filtering, grouping, and sorting which we can potentially tap into later.
+// See https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/query-data-web-api#odata-query-options
+func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	var fullURL string
+
+	if len(config.NextPage) == 0 {
+		// First page
+		relativeURL := config.ObjectName + makeQueryValues(config)
+		fullURL = c.getURL(relativeURL)
+	} else {
+		// Next page
+		fullURL = config.NextPage.String()
+	}
+
+	// always include annotations header
+	// response will describe enums, foreign relationship, etc.
+	rsp, err := c.get(ctx, fullURL, newPaginationHeader(DefaultPageSize), common.Header{
+		Key:   "Prefer",
+		Value: `odata.include-annotations="*"`,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return common.ParseResult(
+		rsp,
+		getTotalSize,
+		getRecords,
+		getNextRecordsURL,
+		getMarshaledData,
+		config.Fields,
+	)
+}
+
+func newPaginationHeader(pageSize int) common.Header {
+	return common.Header{
+		Key:   "Prefer",
+		Value: fmt.Sprintf("odata.maxpagesize=%v", pageSize),
+	}
+}
+
+func makeQueryValues(config common.ReadParams) string {
+	queryValues := url.Values{}
+
+	if len(config.Fields) != 0 {
+		queryValues.Add("$select", strings.Join(config.Fields, ","))
+	}
+
+	result := queryValues.Encode()
+	if len(result) != 0 {
+		// TODO this is a hack. net/url encodes $. But we rely heavily on it
+		// same problem with "@" ex: @Microsoft.Dynamics.CRM.totalrecordcountlimitexceeded
+		// @ symbol is removed
+		for before, after := range map[string]string{
+			"%24select": "$select",
+		} {
+			result = strings.ReplaceAll(result, before, after)
+		}
+
+		result = strings.ReplaceAll(result, "%40", "@")
+		result = strings.ReplaceAll(result, "%2C", ",")
+
+		return "?" + result
+	}
+
+	return result
+}
diff --git a/microsoftdynamicscrm/write.go b/microsoftdynamicscrm/write.go
new file mode 100644
index 00000000..dc012986
--- /dev/null
+++ b/microsoftdynamicscrm/write.go
@@ -0,0 +1,11 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	panic("not implemented")
+}
diff --git a/mock/connector.go b/mock/connector.go
new file mode 100644
index 00000000..0447bd98
--- /dev/null
+++ b/mock/connector.go
@@ -0,0 +1,93 @@
+package mock
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/providers"
+)
+
+type Connector struct {
+	client *common.JSONHTTPClient
+
+	read               func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error)
+	write              func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error)
+	listObjectMetadata func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error)
+}
+
+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 := &mockParams{
+		read: func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error) {
+			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "read")
+		},
+		write: func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error) {
+			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "write")
+		},
+		listObjectMetadata: func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error) {
+			return nil, fmt.Errorf("%w: %s", ErrNotImplemented, "listObjectMetadata")
+		},
+	}
+	for _, opt := range opts {
+		opt(params)
+	}
+
+	params, err := params.prepare()
+	if err != nil {
+		return nil, err
+	}
+
+	return &Connector{
+		client:             params.client,
+		read:               params.read,
+		write:              params.write,
+		listObjectMetadata: params.listObjectMetadata,
+	}, nil
+}
+
+func (c *Connector) String() string {
+	return "mock"
+}
+
+func (c *Connector) Close() error {
+	return nil
+}
+
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.client
+}
+
+func (c *Connector) HTTPClient() *common.HTTPClient {
+	return c.client.HTTPClient
+}
+
+func (c *Connector) Provider() providers.Provider {
+	return providers.Mock
+}
+
+func (c *Connector) Read(ctx context.Context, params common.ReadParams) (*common.ReadResult, error) {
+	return c.read(ctx, params)
+}
+
+func (c *Connector) Write(ctx context.Context, params common.WriteParams) (*common.WriteResult, error) {
+	return c.write(ctx, params)
+}
+
+func (c *Connector) ListObjectMetadata(
+	ctx context.Context,
+	objectNames []string,
+) (*common.ListObjectMetadataResult, error) {
+	return c.listObjectMetadata(ctx, objectNames)
+}
diff --git a/mock/errors.go b/mock/errors.go
new file mode 100644
index 00000000..871876d2
--- /dev/null
+++ b/mock/errors.go
@@ -0,0 +1,10 @@
+package mock
+
+import (
+	"errors"
+)
+
+var (
+	ErrNotImplemented = errors.New("not implemented")
+	ErrMissingParam   = errors.New("missing required parameter")
+)
diff --git a/mock/params.go b/mock/params.go
new file mode 100644
index 00000000..2c1c4013
--- /dev/null
+++ b/mock/params.go
@@ -0,0 +1,83 @@
+package mock
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+// Option is a function which mutates the hubspot connector configuration.
+type Option func(params *mockParams)
+
+// WithClient sets the http client to use for the connector. Saves some boilerplate.
+func WithClient(client *http.Client) Option {
+	return func(params *mockParams) {
+		WithAuthenticatedClient(client)(params)
+	}
+}
+
+// WithAuthenticatedClient sets the http client to use for the connector. Its usage is optional.
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *mockParams) {
+		params.client = &common.JSONHTTPClient{
+			HTTPClient: &common.HTTPClient{
+				Client:       client,
+				ErrorHandler: common.InterpretError,
+			},
+		}
+	}
+}
+
+// WithRead sets the read function for the connector.
+func WithRead(read func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error)) Option {
+	return func(params *mockParams) {
+		params.read = read
+	}
+}
+
+// WithWrite sets the write function for the connector.
+func WithWrite(write func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error)) Option {
+	return func(params *mockParams) {
+		params.write = write
+	}
+}
+
+// WithListObjectMetadata sets the listObjectMetadata function for the connector.
+func WithListObjectMetadata(
+	listObjectMetadata func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error),
+) Option {
+	return func(params *mockParams) {
+		params.listObjectMetadata = listObjectMetadata
+	}
+}
+
+// mockParams is the internal configuration for the mock connector.
+type mockParams struct {
+	client             *common.JSONHTTPClient // required
+	read               func(ctx context.Context, params common.ReadParams) (*common.ReadResult, error)
+	write              func(ctx context.Context, params common.WriteParams) (*common.WriteResult, error)
+	listObjectMetadata func(ctx context.Context, objectNames []string) (*common.ListObjectMetadataResult, error)
+}
+
+// prepare finalizes and validates the connector configuration, and returns an error if it's invalid.
+func (p *mockParams) prepare() (out *mockParams, err error) {
+	if p.client == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "client")
+	}
+
+	if p.read == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "read")
+	}
+
+	if p.write == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "write")
+	}
+
+	if p.listObjectMetadata == nil {
+		return nil, fmt.Errorf("%w: %s", ErrMissingParam, "listObjectMetadata")
+	}
+
+	return p, nil
+}
diff --git a/providers/catalog.go b/providers/catalog.go
index 15848541..da5c0c43 100644
--- a/providers/catalog.go
+++ b/providers/catalog.go
@@ -5,6 +5,7 @@ package providers
 // ================================================================================
 
 const (
+	Mock                                Provider = "mock"
 	Salesforce                          Provider = "salesforce"
 	Hubspot                             Provider = "hubspot"
 	LinkedIn                            Provider = "linkedIn"
@@ -13,6 +14,7 @@ const (
 	Pipedrive                           Provider = "pipedrive"
 	Copper                              Provider = "copper"
 	ZohoCRM                             Provider = "zohoCRM"
+	Klaviyo                             Provider = "klaviyo"
 	Sellsy                              Provider = "sellsy"
 	Attio                               Provider = "attio"
 	Close                               Provider = "close"
@@ -30,16 +32,17 @@ const (
 	AWeber                              Provider = "aWeber"
 	GetResponse                         Provider = "getResponse"
 	ConstantContact                     Provider = "constantContact"
-	MicrosoftDynamics365Sales           Provider = "microsoftDynamics365Sales"
+	MicrosoftDynamics365CRM             Provider = "microsoftDynamics365CRM"
 	MicrosoftDynamics365BusinessCentral Provider = "microsoftDynamics365BusinessCentral"
 	Gainsight                           Provider = "gainsight"
 	Box                                 Provider = "box"
-	GoogleCalendar                      Provider = "googleCalendar"
 	ZendeskSupport                      Provider = "zendeskSupport"
 	ZendeskChat                         Provider = "zendeskChat"
 	WordPress                           Provider = "wordPress"
 	Airtable                            Provider = "airtable"
 	Slack                               Provider = "slack"
+	HelpScoutMailbox                    Provider = "helpScoutMailbox"
+	Webflow                             Provider = "webflow"
 )
 
 // ================================================================================
@@ -47,6 +50,26 @@ const (
 // ================================================================================
 
 var catalog = CatalogType{ // nolint:gochecknoglobals
+	Mock: {
+		AuthType: None,
+		BaseURL:  "https://not-a-real-domain.mock",
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     true,
+			Read:      true,
+			Subscribe: false,
+			Write:     true,
+		},
+		ProviderOpts: ProviderOpts{
+			"isMock": "true",
+		},
+	},
+
 	// Salesforce configuration
 	Salesforce: {
 		AuthType: Oauth2,
@@ -68,7 +91,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 				Insert: false,
 				Update: false,
 				Upsert: true,
-				Delete: false,
+				Delete: true,
 			},
 			Proxy:     true,
 			Read:      true,
@@ -278,11 +301,40 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
+	// Klaviyo configuration
+	Klaviyo: {
+		AuthType: Oauth2,
+		BaseURL:  "https://a.klaviyo.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 PKCE,
+			AuthURL:                   "https://www.klaviyo.com/oauth/authorize",
+			TokenURL:                  "https://a.klaviyo.com/oauth/token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
 	// Sellsy configuration
 	Sellsy: {
 		AuthType: Oauth2,
 		BaseURL:  "https://api.sellsy.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 PKCE,
 			AuthURL:                   "https://login.sellsy.com/oauth2/authorization",
 			TokenURL:                  "https://login.sellsy.com/oauth2/access-tokens",
 			ExplicitScopesRequired:    false,
@@ -401,7 +453,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 	// Dropbox configuration
 	Dropbox: {
 		AuthType: Oauth2,
-		BaseURL:  "https://api.dropboxapi.com/2/",
+		BaseURL:  "https://api.dropboxapi.com",
 		OauthOpts: OauthOpts{
 			AuthURL:                   "https://www.dropbox.com/oauth2/authorize",
 			TokenURL:                  "https://api.dropboxapi.com/oauth2/token",
@@ -575,6 +627,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		AuthType: Oauth2,
 		BaseURL:  "https://api.calendly.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://auth.calendly.com/oauth/authorize",
 			TokenURL:                  "https://auth.calendly.com/oauth/token",
 			ExplicitScopesRequired:    false,
@@ -642,11 +695,11 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	// MS Sales configuration
-	MicrosoftDynamics365Sales: {
+	MicrosoftDynamics365CRM: {
 		AuthType: Oauth2,
 		BaseURL:  "https://{{.workspace}}.api.crm.dynamics.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 			TokenURL:                  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 			ExplicitScopesRequired:    true,
@@ -765,37 +818,12 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		},
 	},
 
-	GoogleCalendar: {
-		AuthType: Oauth2,
-		BaseURL:  "https://www.googleapis.com/calendar",
-		OauthOpts: OauthOpts{
-			AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
-			TokenURL:                  "https://oauth2.googleapis.com/token",
-			ExplicitScopesRequired:    true,
-			ExplicitWorkspaceRequired: false,
-			TokenMetadataFields: TokenMetadataFields{
-				ScopesField: "scope",
-			},
-		},
-		Support: Support{
-			BulkWrite: BulkWriteSupport{
-				Insert: false,
-				Update: false,
-				Upsert: false,
-				Delete: false,
-			},
-			Proxy:     false,
-			Read:      false,
-			Subscribe: false,
-			Write:     false,
-		},
-	},
-
 	// Zendesk Support configuration
 	ZendeskSupport: {
 		AuthType: Oauth2,
 		BaseURL:  "https://{{.workspace}}.zendesk.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://{{.workspace}}.zendesk.com/oauth/authorizations/new",
 			TokenURL:                  "https://{{.workspace}}.zendesk.com/oauth/tokens",
 			ExplicitScopesRequired:    true,
@@ -819,6 +847,7 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 		AuthType: Oauth2,
 		BaseURL:  "https://www.zopim.com",
 		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
 			AuthURL:                   "https://www.zopim.com/oauth2/authorizations/new?subdomain={{.workspace}}",
 			TokenURL:                  "https://www.zopim.com/oauth2/token",
 			ExplicitScopesRequired:    true,
@@ -918,4 +947,56 @@ var catalog = CatalogType{ // nolint:gochecknoglobals
 			Write:     false,
 		},
 	},
+	// HelpScoutMailbox Support Configuration
+	HelpScoutMailbox: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.helpscout.net",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://secure.helpscout.net/authentication/authorizeClientApplication",
+			TokenURL:                  "https://api.helpscout.net/v2/oauth2/token",
+			ExplicitScopesRequired:    false,
+			ExplicitWorkspaceRequired: false,
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
+
+	// Webflow Support Configuration
+	Webflow: {
+		AuthType: Oauth2,
+		BaseURL:  "https://api.webflow.com",
+		OauthOpts: OauthOpts{
+			GrantType:                 AuthorizationCode,
+			AuthURL:                   "https://webflow.com/oauth/authorize",
+			TokenURL:                  "https://api.webflow.com/oauth/access_token",
+			ExplicitScopesRequired:    true,
+			ExplicitWorkspaceRequired: false,
+			TokenMetadataFields: TokenMetadataFields{
+				ScopesField: "scope",
+			},
+		},
+		Support: Support{
+			BulkWrite: BulkWriteSupport{
+				Insert: false,
+				Update: false,
+				Upsert: false,
+				Delete: false,
+			},
+			Proxy:     false,
+			Read:      false,
+			Subscribe: false,
+			Write:     false,
+		},
+	},
 }
diff --git a/providers/types.gen.go b/providers/types.gen.go
index 2bebb1ce..bdbb7b07 100644
--- a/providers/types.gen.go
+++ b/providers/types.gen.go
@@ -5,6 +5,7 @@ package providers
 
 // Defines values for AuthType.
 const (
+	None   AuthType = "none"
 	Oauth2 AuthType = "oauth2"
 )
 
diff --git a/providers/types.yaml b/providers/types.yaml
index cfae4d96..b3fe8e31 100644
--- a/providers/types.yaml
+++ b/providers/types.yaml
@@ -16,7 +16,7 @@ components:
   schemas:
     AuthType:
       type: string
-      enum: [oauth2]
+      enum: [oauth2, none]
       x-oapi-codegen-extra-tags:
         validate: required
 
diff --git a/salesforce/read.go b/salesforce/read.go
index d2979bb0..55cbd820 100644
--- a/salesforce/read.go
+++ b/salesforce/read.go
@@ -20,7 +20,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	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)
+		location, joinErr := url.JoinPath("https://"+c.Domain, config.NextPage.String())
 		if joinErr != nil {
 			return nil, joinErr
 		}
diff --git a/scripts/proxy/proxy.go b/scripts/proxy/proxy.go
index 7f381c63..fd9b1fa4 100644
--- a/scripts/proxy/proxy.go
+++ b/scripts/proxy/proxy.go
@@ -13,7 +13,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/amp-labs/connectors/basic"
+	"github.com/amp-labs/connectors/connector"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/utils"
 	"golang.org/x/oauth2"
@@ -203,7 +203,6 @@ func buildProxy(provider string, scopes []string, clientId, clientSecret string,
 func getProviderConfig(provider string, substitutions map[string]string) *providers.ProviderInfo {
 	config, err := providers.ReadInfo(provider, &substitutions)
 	if err != nil {
-
 		panic(fmt.Errorf("%w: %s", err, provider))
 	}
 
@@ -227,9 +226,9 @@ func configureOAuth(clientId, clientSecret string, scopes []string, providerInfo
 func setupHttpClient(cfg *oauth2.Config, accessToken, refreshToken, provider string, expiry time.Time, providerInfo *providers.ProviderInfo) *http.Client {
 	ctx := context.Background()
 
-	conn, err := basic.NewConnector(
+	conn, err := connector.NewConnector(
 		provider,
-		basic.WithClient(ctx, http.DefaultClient, cfg, &oauth2.Token{
+		connector.WithClient(ctx, http.DefaultClient, cfg, &oauth2.Token{
 			AccessToken:  accessToken,
 			RefreshToken: refreshToken,
 			Expiry:       expiry, // will trigger reuse of refresh token
diff --git a/utils/credentials.go b/utils/credentials.go
index fe0be17b..6971b7a4 100644
--- a/utils/credentials.go
+++ b/utils/credentials.go
@@ -89,3 +89,41 @@ func HubspotOAuthConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config
 
 	return cfg
 }
+
+var MSDynamics365SalesWorkspace = "org5bd08fdd" //nolint:gochecknoglobals
+
+func MSDynamics365SalesConfigFromRegistry(registry CredentialsRegistry) *oauth2.Config {
+	clientId := registry.MustString(ClientId)
+	clientSecret := registry.MustString(ClientSecret)
+
+	cfg := &oauth2.Config{
+		ClientID:     clientId,
+		ClientSecret: clientSecret,
+		RedirectURL:  "http://localhost:8080/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
+			TokenURL:  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{
+			fmt.Sprintf("https://%v.crm.dynamics.com/user_impersonation", MSDynamics365SalesWorkspace),
+			"offline_access",
+		},
+	}
+
+	return cfg
+}
+
+func MSDynamics365SalesTokenFromRegistry(registry CredentialsRegistry) *oauth2.Token {
+	accessToken := registry.MustString(AccessToken)
+	refreshToken := registry.MustString(RefreshToken)
+
+	tok := &oauth2.Token{
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+		TokenType:    "bearer",
+		Expiry:       time.Now().Add(-1 * time.Hour), // just pretend it's expired already, whatever, it'll fetch a new one.
+	}
+
+	return tok
+}


Test patch:
diff --git a/microsoftdynamicscrm/errors_test.go b/microsoftdynamicscrm/errors_test.go
new file mode 100644
index 00000000..2f2cebb6
--- /dev/null
+++ b/microsoftdynamicscrm/errors_test.go
@@ -0,0 +1,95 @@
+package microsoftdynamicscrm
+
+import (
+	"errors"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+func TestInterpretJSONError(t *testing.T) { //nolint:funlen
+	t.Parallel()
+
+	type input struct {
+		res  *http.Response
+		body []byte
+	}
+
+	tests := []struct {
+		name        string
+		input       input
+		expectedErr error
+	}{
+		{
+			name: "Missing response body cannot be unmarshalled",
+			input: input{
+				res:  nil,
+				body: nil,
+			},
+			expectedErr: interpreter.ErrUnmarshal,
+		},
+		{
+			name: "Empty response body cannot be unmarshalled",
+			input: input{
+				res:  nil,
+				body: []byte(``),
+			},
+			expectedErr: interpreter.ErrUnmarshal,
+		},
+		{
+			// TODO should it indicate that connector could handle it?
+			// TODO It would be bad to mask partial API implementation with actual caller error
+			name: "Unknown response status produces caller error",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusTeapot,
+				},
+				body: []byte(`{}`),
+			},
+			expectedErr: common.ErrCaller,
+		},
+		{
+			name: "Correct status of TooManyRequests",
+			input: input{
+				res: &http.Response{
+					StatusCode: http.StatusTooManyRequests,
+				},
+				body: []byte(`{"data":{"base":"BTC","currency":"USD","amount":4225.87}}`),
+			},
+			expectedErr: common.ErrLimitExceeded,
+		},
+	}
+
+	connector := Connector{}
+
+	for _, tt := range tests {
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			err := connector.interpretJSONError(tt.input.res, tt.input.body)
+			if !errors.Is(err, tt.expectedErr) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expectedErr, err)
+			}
+		})
+	}
+
+	t.Run("Correct interpretation of error payload", func(t *testing.T) {
+		t.Parallel()
+
+		err := connector.interpretJSONError(&http.Response{
+			StatusCode: http.StatusBadRequest,
+		}, []byte(`{  
+				 "error":{  
+				  "code": "<This code is not related to the http status code and is frequently empty>",  
+				  "message": "<A message describing the error>"  
+				 }  
+				}`))
+		if !strings.Contains(err.Error(), "<A message describing the error>") {
+			t.Fatalf("expected errot type mismatched for: (%v)", err)
+		}
+	})
+}
diff --git a/microsoftdynamicscrm/read_test.go b/microsoftdynamicscrm/read_test.go
new file mode 100644
index 00000000..e642fa59
--- /dev/null
+++ b/microsoftdynamicscrm/read_test.go
@@ -0,0 +1,291 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+	"github.com/spyzhov/ajson"
+)
+
+func TestMakeQueryValues(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    common.ReadParams
+		expected string
+	}{
+		{
+			name:     "No params no query string",
+			input:    common.ReadParams{},
+			expected: "",
+		},
+		{
+			name: "One parameter",
+			input: common.ReadParams{
+				Fields: []string{"cat"},
+			},
+			expected: "?$select=cat",
+		},
+		{
+			name: "Many parameters",
+			input: common.ReadParams{
+				Fields: []string{"cat", "dog", "parrot", "hamster"},
+			},
+			expected: "?$select=cat,dog,parrot,hamster",
+		},
+		{
+			name: "OData parameters with @ symbol",
+			input: common.ReadParams{
+				Fields: []string{"cat", "@odata.dog", "parrot"},
+			},
+			expected: "?$select=cat,@odata.dog,parrot",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output := makeQueryValues(tt.input)
+			if !reflect.DeepEqual(output, tt.expected) {
+				t.Fatalf("%s: expected: (%v), got: (%v)", tt.name, tt.expected, output)
+			}
+		})
+	}
+}
+
+func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	fakeServerResp, err := mockutils.DataFromFile("read.json")
+	if err != nil {
+		t.Fatalf("failed to start test, input file missing, %v", err)
+	}
+
+	tests := []struct {
+		name             string
+		input            common.ReadParams
+		server           *httptest.Server
+		connector        Connector
+		expected         *common.ReadResult
+		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: "Correct error message is understood from JSON response",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				writeBody(w, `{
+					"error": {
+						"code": "0x80060888",
+						"message":"Resource not found for the segment 'conacs'."
+					}
+				}`)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest, errors.New("Resource not found for the segment 'conacs'"), // nolint:goerr113
+			},
+		},
+		{
+			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)
+				writeBody(w, `{
+					"garbage": {}
+				}`)
+			})),
+			expectedErrTypes: []error{ajson.Error{}},
+		},
+		{
+			name: "Incorrect data type 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)
+				writeBody(w, `{
+					"value": {}
+				}`)
+			})),
+			expectedErrs: []error{common.ErrNotArray},
+		},
+		{
+			name: "Next page cursor may be missing 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)
+				writeBody(w, `{
+					"value": []
+				}`)
+			})),
+			expected: &common.ReadResult{
+				Data: []common.ReadResultRow{},
+				Done: true,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with 2 entries",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(fakeServerResp)
+			})),
+			expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372108\"",
+						"fullname":      "Heriberto Nathan",
+						"emailaddress1": "heriberto@northwindtraders.com",
+						"fax":           "614-555-0122",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "cdcfa450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}, {
+					Fields: map[string]any{},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372115\"",
+						"fullname":      "Dwayne Elijah",
+						"emailaddress1": "dwayne@alpineskihouse.com",
+						"fax":           "281-555-0158",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "9fd4a450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}},
+				NextPage: "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/contacts?$select=fullname,emailaddress1,fax,familystatuscode&$skiptoken=%3Ccookie%20pagenumber=%222%22%20pagingcookie=%22%253ccookie%2520page%253d%25221%2522%253e%253ccontactid%2520last%253d%2522%257b9FD4A450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520first%253d%2522%257bCDCFA450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520%252f%253e%253c%252fcookie%253e%22%20istracking=%22False%22%20/%3E", // nolint:lll
+				Done:     false,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name: "Successful read with chosen fields",
+			input: common.ReadParams{
+				Fields: []string{"fullname", "fax"},
+			},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				_, _ = w.Write(fakeServerResp)
+			})),
+			expected: &common.ReadResult{
+				Rows: 2,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"fullname": "Heriberto Nathan",
+						"fax":      "614-555-0122",
+					},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372108\"",
+						"fullname":      "Heriberto Nathan",
+						"emailaddress1": "heriberto@northwindtraders.com",
+						"fax":           "614-555-0122",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "cdcfa450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}, {
+					Fields: map[string]any{
+						"fullname": "Dwayne Elijah",
+						"fax":      "281-555-0158",
+					},
+					Raw: map[string]any{
+						"@odata.etag":   "W/\"4372115\"",
+						"fullname":      "Dwayne Elijah",
+						"emailaddress1": "dwayne@alpineskihouse.com",
+						"fax":           "281-555-0158",
+						"familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+						"familystatuscode": float64(1),
+						"contactid":        "9fd4a450-cb0c-ea11-a813-000d3a1b1223",
+					},
+				}},
+				NextPage: "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/contacts?$select=fullname,emailaddress1,fax,familystatuscode&$skiptoken=%3Ccookie%20pagenumber=%222%22%20pagingcookie=%22%253ccookie%2520page%253d%25221%2522%253e%253ccontactid%2520last%253d%2522%257b9FD4A450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520first%253d%2522%257bCDCFA450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520%252f%253e%253c%252fcookie%253e%22%20istracking=%22False%22%20/%3E", // nolint:lll
+				Done:     false,
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	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()
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructin connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our mock server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Read(ctx, tt.input)
+			if err != nil {
+				if len(tt.expectedErrs)+len(tt.expectedErrTypes) == 0 {
+					t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+				}
+			} else {
+				// check that missing error is what is expected
+				if len(tt.expectedErrs) != 0 {
+					t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+				}
+
+				if len(tt.expectedErrTypes) != 0 {
+					t.Fatalf("%s: expected error types (%v), but got nothing", tt.name, tt.expectedErrTypes)
+				}
+			}
+
+			// check every error
+			for _, expectedErr := range tt.expectedErrTypes {
+				if reflect.TypeOf(err) != reflect.TypeOf(expectedErr) {
+					t.Fatalf("%s: expected Error type: (%T), got: (%T)", tt.name, expectedErr, err)
+				}
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			// compare desired output
+			if !reflect.DeepEqual(output, tt.expected) {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
+
+func writeBody(w http.ResponseWriter, body string) {
+	_, _ = w.Write([]byte(body))
+}
diff --git a/microsoftdynamicscrm/test/read.json b/microsoftdynamicscrm/test/read.json
new file mode 100644
index 00000000..56227274
--- /dev/null
+++ b/microsoftdynamicscrm/test/read.json
@@ -0,0 +1,27 @@
+{
+  "@odata.context": "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/$metadata#contacts(fullname,emailaddress1,fax,familystatuscode)",
+  "@Microsoft.Dynamics.CRM.totalrecordcount": -1,
+  "@Microsoft.Dynamics.CRM.totalrecordcountlimitexceeded": false,
+  "@Microsoft.Dynamics.CRM.globalmetadataversion": "6012567",
+  "value": [
+    {
+      "@odata.etag": "W/\"4372108\"",
+      "fullname": "Heriberto Nathan",
+      "emailaddress1": "heriberto@northwindtraders.com",
+      "fax": "614-555-0122",
+      "familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+      "familystatuscode": 1,
+      "contactid": "cdcfa450-cb0c-ea11-a813-000d3a1b1223"
+    },
+    {
+      "@odata.etag": "W/\"4372115\"",
+      "fullname": "Dwayne Elijah",
+      "emailaddress1": "dwayne@alpineskihouse.com",
+      "fax": "281-555-0158",
+      "familystatuscode@OData.Community.Display.V1.FormattedValue": "Single",
+      "familystatuscode": 1,
+      "contactid": "9fd4a450-cb0c-ea11-a813-000d3a1b1223"
+    }
+  ],
+  "@odata.nextLink": "https://org5bd08fdd.api.crm.dynamics.com/api/data/v9.2/contacts?$select=fullname,emailaddress1,fax,familystatuscode&$skiptoken=%3Ccookie%20pagenumber=%222%22%20pagingcookie=%22%253ccookie%2520page%253d%25221%2522%253e%253ccontactid%2520last%253d%2522%257b9FD4A450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520first%253d%2522%257bCDCFA450-CB0C-EA11-A813-000D3A1B1223%257d%2522%2520%252f%253e%253c%252fcookie%253e%22%20istracking=%22False%22%20/%3E"
+}
diff --git a/providers/catalog_test.go b/providers/catalog_test.go
index bdb60fa5..0cc2f47f 100644
--- a/providers/catalog_test.go
+++ b/providers/catalog_test.go
@@ -28,7 +28,7 @@ var testCases = []struct { // nolint
 					Insert: false,
 					Update: false,
 					Upsert: true,
-					Delete: false,
+					Delete: true,
 				},
 				Subscribe: false,
 				Proxy:     true,
@@ -296,11 +296,44 @@ var testCases = []struct { // nolint
 		expectedErr: nil,
 	},
 
+	{
+		provider:    Klaviyo,
+		description: "Valid Klaviyo provider config with no substitutions",
+		expected: &ProviderInfo{
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 "PKCE",
+				AuthURL:                   "https://www.klaviyo.com/oauth/authorize",
+				TokenURL:                  "https://a.klaviyo.com/oauth/token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			BaseURL: "https://a.klaviyo.com",
+		},
+		expectedErr: nil,
+	},
+
 	{
 		provider: Sellsy,
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 PKCE,
 				AuthURL:                   "https://login.sellsy.com/oauth2/authorization",
 				TokenURL:                  "https://login.sellsy.com/oauth2/access-tokens",
 				ExplicitScopesRequired:    false,
@@ -460,7 +493,7 @@ var testCases = []struct { // nolint
 				Subscribe: false,
 				Write:     false,
 			},
-			BaseURL: "https://api.dropboxapi.com/2/",
+			BaseURL: "https://api.dropboxapi.com",
 		},
 		expectedErr: nil,
 	},
@@ -652,6 +685,7 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://auth.calendly.com/oauth/authorize",
 				TokenURL:                  "https://auth.calendly.com/oauth/token",
 				ExplicitScopesRequired:    false,
@@ -716,8 +750,8 @@ var testCases = []struct { // nolint
 		expectedErr: nil,
 	},
 	{
-		provider:    MicrosoftDynamics365Sales,
-		description: "MS Dynamics 365 Sales provider config with valid substitutions",
+		provider:    MicrosoftDynamics365CRM,
+		description: "MS Dynamics 365 CRM provider config with valid substitutions",
 		substitutions: map[string]string{
 			"workspace": "testing",
 		},
@@ -736,6 +770,7 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
 				TokenURL:                  "https://login.microsoftonline.com/common/oauth2/v2.0/token",
 				ExplicitScopesRequired:    true,
@@ -862,36 +897,6 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
-	{
-		provider:    GoogleCalendar,
-		description: "Google Calendar provider config with no substitutions",
-		expected: &ProviderInfo{
-			Support: Support{
-				Read:  false,
-				Write: false,
-				BulkWrite: BulkWriteSupport{
-					Insert: false,
-					Update: false,
-					Upsert: false,
-					Delete: false,
-				},
-				Subscribe: false,
-				Proxy:     false,
-			},
-			AuthType: Oauth2,
-			OauthOpts: OauthOpts{
-				AuthURL:                   "https://accounts.google.com/o/oauth2/v2/auth",
-				TokenURL:                  "https://oauth2.googleapis.com/token",
-				ExplicitWorkspaceRequired: false,
-				ExplicitScopesRequired:    true,
-				TokenMetadataFields: TokenMetadataFields{
-					ScopesField: "scope",
-				},
-			},
-			BaseURL: "https://www.googleapis.com/calendar",
-		},
-		expectedErr: nil,
-	},
 	{
 		provider:    ZendeskSupport,
 		description: "Zendesk Support provider config with valid substitutions",
@@ -913,6 +918,7 @@ var testCases = []struct { // nolint
 			},
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://testing.zendesk.com/oauth/authorizations/new",
 				TokenURL:                  "https://testing.zendesk.com/oauth/tokens",
 				ExplicitScopesRequired:    true,
@@ -931,6 +937,7 @@ var testCases = []struct { // nolint
 		expected: &ProviderInfo{
 			AuthType: Oauth2,
 			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
 				AuthURL:                   "https://www.zopim.com/oauth2/authorizations/new?subdomain=test",
 				TokenURL:                  "https://www.zopim.com/oauth2/token",
 				ExplicitScopesRequired:    true,
@@ -1042,6 +1049,65 @@ var testCases = []struct { // nolint
 		},
 		expectedErr: nil,
 	},
+	{
+		provider:    HelpScoutMailbox,
+		description: "Valid HelpScoutMailbox provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://secure.helpscout.net/authentication/authorizeClientApplication",
+				TokenURL:                  "https://api.helpscout.net/v2/oauth2/token",
+				ExplicitScopesRequired:    false,
+				ExplicitWorkspaceRequired: false,
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.helpscout.net",
+		},
+		expectedErr: nil,
+	},
+	{
+		provider:    Webflow,
+		description: "Valid Webflow provider config with no substitutions",
+		expected: &ProviderInfo{
+			AuthType: Oauth2,
+			OauthOpts: OauthOpts{
+				GrantType:                 AuthorizationCode,
+				AuthURL:                   "https://webflow.com/oauth/authorize",
+				TokenURL:                  "https://api.webflow.com/oauth/access_token",
+				ExplicitScopesRequired:    true,
+				ExplicitWorkspaceRequired: false,
+				TokenMetadataFields: TokenMetadataFields{
+					ScopesField: "scope",
+				},
+			},
+			Support: Support{
+				BulkWrite: BulkWriteSupport{
+					Insert: false,
+					Update: false,
+					Upsert: false,
+					Delete: false,
+				},
+				Proxy:     false,
+				Read:      false,
+				Subscribe: false,
+				Write:     false,
+			},
+			BaseURL: "https://api.webflow.com",
+		},
+		expectedErr: nil,
+	},
 }
 
 func TestReadInfo(t *testing.T) { // nolint
diff --git a/test/microsoftdynamicscrm/README.md b/test/microsoftdynamicscrm/README.md
new file mode 100644
index 00000000..0f8e552a
--- /dev/null
+++ b/test/microsoftdynamicscrm/README.md
@@ -0,0 +1,25 @@
+# Running instructions
+
+## Step 1: prepare "ms-dales-creds.json" file
+
+Create a file called "ms-sales-creds.json" in the root of the project with the following contents
+
+    e.g. {
+        "CLIENT_ID": "<client id goes here>",
+        "CLIENT_SECRET": "<client secret goes here>",
+        "ACCESS_TOKEN": "<access token goes here>",
+        "REFRESH_TOKEN": "<refresh token goes here>"
+    }
+
+or export to an environment variable MS_SALES_CRED_FILE by following command
+
+$> export MS_SALES_CRED_FILE=./ms-sales-creds.json # or the path to your ms-sales-creds.json file
+
+
+In 1password, you can find a MS Sales creds.json file in the "Shared" vault. TODO this must be in 1password
+Look for the title "MS Sales Sample OAuth Credentials".
+The 1password item has an attached file called "creds.json" that contains the JSON.
+
+## Step 2: run the following command
+
+    $> go run test/msdsales/read/main.go
\ No newline at end of file
diff --git a/test/microsoftdynamicscrm/connector.go b/test/microsoftdynamicscrm/connector.go
new file mode 100644
index 00000000..221fdea7
--- /dev/null
+++ b/test/microsoftdynamicscrm/connector.go
@@ -0,0 +1,58 @@
+package microsoftdynamicscrm
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	testUtils "github.com/amp-labs/connectors/test/utils"
+	"github.com/amp-labs/connectors/utils"
+)
+
+func GetMSDynamics365SalesConnector(ctx context.Context, filePath string) *microsoftdynamicscrm.Connector {
+	registry := utils.NewCredentialsRegistry()
+
+	readers := []utils.Reader{
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.CLIENT_ID",
+			CredKey:  "clientId",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.CLIENT_SECRET",
+			CredKey:  "clientSecret",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.ACCESS_TOKEN",
+			CredKey:  "accessToken",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.REFRESH_TOKEN",
+			CredKey:  "refreshToken",
+		},
+		&utils.JSONReader{
+			FilePath: filePath,
+			JSONPath: "$.PROVIDER",
+			CredKey:  "provider",
+		},
+	}
+	_ = registry.AddReaders(readers...)
+
+	cfg := utils.MSDynamics365SalesConfigFromRegistry(registry)
+	tok := utils.MSDynamics365SalesTokenFromRegistry(registry)
+
+	conn, err := connectors.MSDynamicsSales(
+		microsoftdynamicscrm.WithClient(ctx, http.DefaultClient, cfg, tok),
+		microsoftdynamicscrm.WithWorkspace(utils.MSDynamics365SalesWorkspace),
+		microsoftdynamicscrm.WithModule(microsoftdynamicscrm.DefaultModuleCRM),
+	)
+	if err != nil {
+		testUtils.Fail("error creating microsoft sales connector", "error", err)
+	}
+
+	return conn
+}
diff --git a/test/microsoftdynamicscrm/read/main.go b/test/microsoftdynamicscrm/read/main.go
new file mode 100644
index 00000000..b0478629
--- /dev/null
+++ b/test/microsoftdynamicscrm/read/main.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
+	msTest "github.com/amp-labs/connectors/test/microsoftdynamicscrm"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	filePath := os.Getenv("MS_SALES_CRED_FILE")
+	if filePath == "" {
+		filePath = "./ms-sales-creds.json"
+	}
+
+	conn := msTest.GetMSDynamics365SalesConnector(ctx, filePath)
+	defer utils.Close(conn)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "contacts",
+		Fields: []string{
+			"fullname", "emailaddress1", "fax",
+		},
+	})
+	if err != nil {
+		utils.Fail("error reading from microsoft sales", "error", err)
+	}
+
+	fmt.Println("Reading contacts..")
+	utils.DumpJSON(res, os.Stdout)
+
+	if res.Rows > microsoftdynamicscrm.DefaultPageSize {
+		utils.Fail(fmt.Sprintf("expected max %v rows", microsoftdynamicscrm.DefaultPageSize))
+	}
+}
diff --git a/test/salesforce/read-write/read-write.go b/test/salesforce/read-write/read-write.go
index a8aaca0f..54201965 100644
--- a/test/salesforce/read-write/read-write.go
+++ b/test/salesforce/read-write/read-write.go
@@ -66,7 +66,13 @@ func mainFn() int { //nolint:funlen
 		_ = sfc.Close()
 	}()
 
-	if err := testConnector(ctx, sfc); err != nil {
+	if err := testReadConnector(ctx, sfc); err != nil {
+		slog.Error("Error testing", "connector", sfc, "error", err)
+
+		return 1
+	}
+
+	if err := testWriteConnector(ctx, sfc); err != nil {
 		slog.Error("Error testing", "connector", sfc, "error", err)
 
 		return 1
@@ -75,7 +81,7 @@ func mainFn() int { //nolint:funlen
 	return 0
 }
 
-func testConnector(ctx context.Context, conn connectors.Connector) error {
+func testReadConnector(ctx context.Context, conn connectors.ReadConnector) error {
 	// Create a context with a timeout
 	ctx, done := context.WithTimeout(ctx, TimeoutSeconds*time.Second)
 	defer done()
@@ -98,6 +104,10 @@ func testConnector(ctx context.Context, conn connectors.Connector) error {
 	_, _ = os.Stdout.Write(jsonStr)
 	_, _ = os.Stdout.WriteString("\n")
 
+	return nil
+}
+
+func testWriteConnector(ctx context.Context, conn connectors.WriteConnector) error {
 	// IMPORTANT: every time this test is run, it will create a new Account
 	// in SFDC instance. Will need to delete those out at later date.
 	writtenRecordId, err := testSalesforceValidCreate(ctx, conn)
@@ -115,7 +125,7 @@ func testConnector(ctx context.Context, conn connectors.Connector) error {
 const accountNumber = 123
 
 // testSalesforceValidCreate will create a valid record in Salesforce.
-func testSalesforceValidCreate(ctx context.Context, conn connectors.Connector) (string, error) {
+func testSalesforceValidCreate(ctx context.Context, conn connectors.WriteConnector) (string, error) {
 	writeRes, err := conn.Write(ctx, connectors.WriteParams{
 		ObjectName: "Account",
 		RecordData: map[string]interface{}{
@@ -142,7 +152,7 @@ func testSalesforceValidCreate(ctx context.Context, conn connectors.Connector) (
 const accountNumber2 = 456
 
 // testSalesforceValidUpdate will update existing record in Salesforce.
-func testSalesforceValidUpdate(ctx context.Context, conn connectors.Connector, writtenRecordId string) error {
+func testSalesforceValidUpdate(ctx context.Context, conn connectors.WriteConnector, writtenRecordId string) error {
 	writeRes, err := conn.Write(ctx, connectors.WriteParams{
 		ObjectName: "Account",
 		RecordData: map[string]interface{}{
diff --git a/test/utils/mockutils/fileReader.go b/test/utils/mockutils/fileReader.go
new file mode 100644
index 00000000..b6b24f33
--- /dev/null
+++ b/test/utils/mockutils/fileReader.go
@@ -0,0 +1,24 @@
+package mockutils
+
+import (
+	"os"
+	"path"
+	"runtime"
+	"strings"
+)
+
+// DataFromFile is used for server mocking.
+// Files must be located under ./test directory relative to the test runner.
+func DataFromFile(testFileName string) ([]byte, error) {
+	_, parentCallerLocation, _, _ := runtime.Caller(1) // nolint:dogsled
+
+	workingDir, err := os.Getwd()
+	if err != nil {
+		return nil, err
+	}
+
+	relativePath, _ := strings.CutPrefix(parentCallerLocation, workingDir)
+	testDir := path.Join(".", relativePath, "../test")
+
+	return os.ReadFile(testDir + "/" + testFileName)
+}