Repository: amp-labs/connectors
Pull request #157 (2024-04-03 13:11:28)

feat: Deep connector MS Dynamics365 Sales (Read)
Closes https://github.com/amp-labs/connectors/issues/186


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/connectors.go b/connectors.go
index 6d9d4631..df739412 100644
--- a/connectors.go
+++ b/connectors.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/hubspot"
+	"github.com/amp-labs/connectors/microsoftdynamicscrm"
 	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/salesforce"
 )
@@ -76,6 +77,9 @@ 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
+
 // We re-export the following types so that they can be used by consumers of this library.
 type (
 	ReadParams               = common.ReadParams
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/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/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/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/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)
+}