Repository: amp-labs/connectors
Pull request #704 (2024-07-18 17:43:23)

fix: DynamicsCRM ListObjectMetadata readable fields only, add DisplayNames
# Problem

Not all fields returned by `ListObjectMetadata` can be used by `Read`. There are multiple endpoints to get metadata each has its differences.
Its funny how some fields are not allowed to be used for query parameters on Collection resource but on Single resource would ignore and not produce an error. We have to deal with it.

# Solution

I found a total replacment of XML schema request ($metadata endpoint) by using EntityDefinitions.Attributes endpoint with certian parameters.
After investigation I developed steps to return fields just like before, but taking into account the problem described above!

Here is a short pseudo algorithm. For every object name:
* make request to `EntityDefinitions` to get DisplayName of the object
* make request to `EntityDefinitions.Attributes` such that
    * IsValidODataAttribute is true - field can be found in Read response
    * IsValidForRead is true - is allowed to be used in query fields

Algorithm for infering display name of an Attribute:
* Pick Localized Display name if available, otherwise
* Pick SchemaName, otherwise
* Default: display name will be same as field name.

## Edge Case
There is one more extra edge case. Addressing it opened a possiblity of a **full switch to the new endpoint**.
Some fields in Read payload are in the form of `_<some-name>_value`. Contacts has 25 underscore fields. There are 25 attributes with `Targets` field. Turns out this is the patern among entities. In conclusion, we manually format the field, while harnessing human readable display names.

Ex: Attribute name is `createdby`, since it has targets, the field name is `_createdby_value` with `Created By` display.

# Tests

The script of comparying set of fields from Read and ListObjectMetadata is no longer applicable. Reason is ListObjectMetadata can be only a subset of Read because of `IsValidForRead` rule.
A new test asks Metadata first. Then uses output to query fields by Read method. This test also fulfills the direct purpose of ListObjectMetadata.

Mock tests imitate 2 API calls triggered by ListObjectMetadata.


RajatPawar: Left a couple of comments, please make the changes wherever applicable (or drop a comment if you think they aren't applicable) before merging. 
Cobalt0s: * **#704** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/704?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* `main`

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @Cobalt0s and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/common/handy/maps.go b/common/handy/maps.go
new file mode 100644
index 00000000..b527b678
--- /dev/null
+++ b/common/handy/maps.go
@@ -0,0 +1,47 @@
+package handy
+
+// Map is a generic version of map with useful methods.
+// It can return Keys as a slice or a Set.
+type Map[K comparable, V any] map[K]V
+
+func (m Map[K, V]) Keys() []K {
+	keys := make([]K, 0)
+	for k := range m {
+		keys = append(keys, k)
+	}
+
+	return keys
+}
+
+func (m Map[K, V]) KeySet() Set[K] {
+	return NewSet(m.Keys())
+}
+
+type Set[V comparable] map[V]struct{}
+
+func NewSet[V comparable](values []V) Set[V] {
+	result := make(Set[V])
+	for _, v := range values {
+		result[v] = struct{}{}
+	}
+
+	return result
+}
+
+func (s Set[V]) Diff(other Set[V]) []V {
+	difference := s.Subtract(other)
+
+	return append(difference, other.Subtract(s)...)
+}
+
+func (s Set[V]) Subtract(other Set[V]) []V {
+	difference := make([]V, 0)
+
+	for v := range s {
+		if _, ok := other[v]; !ok {
+			difference = append(difference, v)
+		}
+	}
+
+	return difference
+}
diff --git a/common/json.go b/common/json.go
index b8565c69..44593a03 100644
--- a/common/json.go
+++ b/common/json.go
@@ -104,7 +104,12 @@ func (j *JSONHTTPClient) Delete(ctx context.Context, url string, headers ...Head
 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
+		return &JSONHTTPResponse{
+			bodyBytes: body,
+			Code:      res.StatusCode,
+			Headers:   res.Header,
+			Body:      nil,
+		}, nil
 	}
 	// Ensure the response is JSON
 	ct := res.Header.Get("Content-Type")
diff --git a/common/types.go b/common/types.go
index 0913537b..49a83d98 100644
--- a/common/types.go
+++ b/common/types.go
@@ -150,9 +150,9 @@ type ReadResult struct {
 type ReadResultRow struct {
 	// Fields is a map of requested provider field names to values.
 	// All field names are in lowercase (eg: accountid, name, billingcityid)
-	Fields map[string]interface{} `json:"fields"`
+	Fields map[string]any `json:"fields"`
 	// Raw is the raw JSON response from the provider.
-	Raw map[string]interface{} `json:"raw"`
+	Raw map[string]any `json:"raw"`
 }
 
 // WriteResult is what's returned from writing data via the Write call.
diff --git a/dynamicscrm/connector.go b/dynamicscrm/connector.go
index 3086269e..e5d35d0f 100644
--- a/dynamicscrm/connector.go
+++ b/dynamicscrm/connector.go
@@ -5,6 +5,7 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/naming"
 	"github.com/amp-labs/connectors/common/paramsbuilder"
 	"github.com/amp-labs/connectors/common/urlbuilder"
 	"github.com/amp-labs/connectors/providers"
@@ -13,9 +14,8 @@ import (
 const apiVersion = "v9.2"
 
 type Connector struct {
-	BaseURL   string
-	Client    *common.JSONHTTPClient
-	XMLClient *common.XMLHTTPClient
+	BaseURL string
+	Client  *common.JSONHTTPClient
 }
 
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
@@ -34,9 +34,6 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 		Client: &common.JSONHTTPClient{
 			HTTPClient: httpClient,
 		},
-		XMLClient: &common.XMLHTTPClient{
-			HTTPClient: httpClient,
-		},
 	}
 
 	providerInfo, err := providers.ReadInfo(conn.Provider(), &params.Workspace)
@@ -65,6 +62,22 @@ func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
 	return constructURL(c.BaseURL, apiVersion, arg)
 }
 
+func (c *Connector) getEntityDefinitionURL(arg naming.SingularString) (*urlbuilder.URL, error) {
+	// This endpoint returns schema of an object.
+	// Schema name must be singular.
+	path := fmt.Sprintf("EntityDefinitions(LogicalName='%v')", arg.String())
+
+	return c.getURL(path)
+}
+
+func (c *Connector) getEntityAttributesURL(arg naming.SingularString) (*urlbuilder.URL, error) {
+	// This endpoint will describe attributes present on schema and its properties.
+	// Schema name must be singular.
+	path := fmt.Sprintf("EntityDefinitions(LogicalName='%v')/Attributes", arg.String())
+
+	return c.getURL(path)
+}
+
 func (c *Connector) setBaseURL(newURL string) {
 	c.BaseURL = newURL
 	c.Client.HTTPClient.Base = newURL
diff --git a/dynamicscrm/entityAttributes.go b/dynamicscrm/entityAttributes.go
new file mode 100644
index 00000000..19137767
--- /dev/null
+++ b/dynamicscrm/entityAttributes.go
@@ -0,0 +1,139 @@
+package dynamicscrm
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/spyzhov/ajson"
+)
+
+// UnderscoreFieldFormat is used to format field names that will be present in Read response.
+// These fields are references used for search.
+const UnderscoreFieldFormat = "_%v_value"
+
+var (
+	ErrObjectNotFound          = errors.New("object not found")
+	ErrObjectMissingAttributes = errors.New("object missing metadata attributes")
+)
+
+// Returns pairs of field names to display names.
+// Internally will make an API call to Attributes endpoint.
+func (c *Connector) getFieldsForObject(
+	ctx context.Context, objectName naming.SingularString,
+) (map[string]string, error) {
+	url, err := c.getEntityAttributesURL(objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	// Filter attributes to ensure they are:
+	// 1. Present in Read responses (IsValidODataAttribute == true)
+	// 2. Can be queried in GET requests (IsValidForRead == true)
+	// This ensures we only work with fields that are both
+	// returned in the payload and can be used in query parameters.
+	url.WithQueryParam("$filter", "(IsValidODataAttribute eq true and IsValidForRead eq true)")
+	// We cannot use $select clause to scope response, unfortunately, `Targets` field breaks $select.
+	// Falling back to requesting the whole payload.
+
+	body, err := c.performGetRequest(ctx, url)
+	if err != nil {
+		return nil, err
+	}
+
+	fields, err := extractFieldsFromJSON(body)
+	if err != nil {
+		return nil, fmt.Errorf("%w: %s", err, objectName)
+	}
+
+	return fields, nil
+}
+
+// Attributes from endpoint response will be converted from JSON objects
+// to field names mapped to display names.
+func extractFieldsFromJSON(attributes *ajson.Node) (map[string]string, error) {
+	array, err := jsonquery.New(attributes).Array("value", false)
+	if err != nil {
+		return nil, errors.Join(ErrObjectNotFound, err)
+	}
+
+	if len(array) == 0 {
+		// nothing to read, we expected some attributes
+		return nil, ErrObjectMissingAttributes
+	}
+
+	fieldsMap := make(map[string]string)
+
+	for _, item := range array {
+		name, displayName, err := getAttributeNames(item)
+		if err != nil {
+			return nil, err
+		}
+
+		fieldsMap[name] = displayName
+	}
+
+	return fieldsMap, nil
+}
+
+// Single attribute payload holds logical name of a field and its display name.
+func getAttributeNames(attribute *ajson.Node) (name string, displayName string, err error) {
+	logicalName, err := jsonquery.New(attribute).Str("LogicalName", false)
+	if err != nil {
+		return "", "", errors.Join(ErrObjectNotFound, err)
+	}
+
+	name = *logicalName
+
+	displayName, err = getAttributeDisplayName(attribute, name)
+	if err != nil {
+		return "", "", errors.Join(ErrObjectNotFound, err)
+	}
+
+	// check if attribute has targets
+	targets, err := jsonquery.New(attribute).Array("Targets", true)
+	if err != nil {
+		return "", "", errors.Join(ErrObjectNotFound, err)
+	}
+
+	if len(targets) > 0 {
+		// This field is a reference to other entities.
+		// Apply underscore formating, because this is how such fields appear in the Read response.
+		name = fmt.Sprintf(UnderscoreFieldFormat, name)
+	}
+
+	return name, displayName, nil
+}
+
+// Display Name is picked based on priority list. Last element is the least preferred fallback.
+//
+// Below is the priority list using EntityDefinitions.Attribute object.
+//
+// 1. DisplayName.LocalizedLabels[0].Label -> ex: Entity Image Id
+// 2. SchemaName -> ex: EntityImageId
+// 3. LogicalName -> ex: entityimageid.
+func getAttributeDisplayName(item *ajson.Node, logicalName string) (string, error) {
+	localizedLabels, err := jsonquery.New(item, "DisplayName").Array("LocalizedLabels", true)
+	if err != nil {
+		return "", err
+	}
+
+	if len(localizedLabels) != 0 {
+		// First occurring label should be sufficient to get to know display name.
+		firstLabel := localizedLabels[0]
+
+		displayLabel, err := jsonquery.New(firstLabel).Str("Label", true)
+		if err != nil {
+			return "", err
+		}
+
+		if displayLabel != nil {
+			return *displayLabel, nil
+		}
+	}
+
+	// try to use SchemaName which has better format than logical name
+	return jsonquery.New(item).StrWithDefault("SchemaName", logicalName)
+}
diff --git a/dynamicscrm/entityDefinition.go b/dynamicscrm/entityDefinition.go
new file mode 100644
index 00000000..21c339fe
--- /dev/null
+++ b/dynamicscrm/entityDefinition.go
@@ -0,0 +1,49 @@
+package dynamicscrm
+
+import (
+	"context"
+	"errors"
+
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/spyzhov/ajson"
+)
+
+// Make a call to EntityDefinition endpoint.
+// We are looking for one field: DisplayCollectionName.
+// This is the formal name of a collection that this ObjectName represents.
+func (c *Connector) getObjectDisplayName(
+	ctx context.Context, objectName naming.SingularString,
+) (string, error) {
+	url, err := c.getEntityDefinitionURL(objectName)
+	if err != nil {
+		return "", err
+	}
+
+	// the only field we care about in response
+	url.WithQueryParam("$select", "DisplayCollectionName")
+
+	body, err := c.performGetRequest(ctx, url)
+	if err != nil {
+		return "", err
+	}
+
+	objectDisplayName, err := getObjectDisplayName(body, objectName)
+	if err != nil {
+		return "", err
+	}
+
+	return objectDisplayName, nil
+}
+
+func getObjectDisplayName(item *ajson.Node, objectName naming.SingularString) (string, error) {
+	// Location of string from root: DisplayCollectionName->LocalizedLabels->Label
+	// If it is not found default to object name pluralised, thats the best we can do.
+	displayName, err := jsonquery.New(item, "DisplayCollectionName", "LocalizedLabels").
+		StrWithDefault("Label", objectName.Plural().String())
+	if err != nil {
+		return "", errors.Join(ErrObjectNotFound, err)
+	}
+
+	return displayName, nil
+}
diff --git a/dynamicscrm/metadata.go b/dynamicscrm/metadata.go
index 5d019c8b..88456e45 100644
--- a/dynamicscrm/metadata.go
+++ b/dynamicscrm/metadata.go
@@ -2,30 +2,23 @@ package dynamicscrm
 
 import (
 	"context"
-	"errors"
-	"fmt"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/naming"
-	"github.com/amp-labs/connectors/common/xquery"
 )
 
-var (
-	CRMMetadataSchemaName = "Microsoft.Dynamics.CRM" // nolint:gochecknoglobals
-
-	ErrMissingSchema      = fmt.Errorf("missing schema %v in response", CRMMetadataSchemaName)
-	ErrMetadataProcessing = errors.New("metadata couldn't be processed")
-	ErrObjectNotFound     = errors.New("object not found")
-)
-
-// ListObjectMetadata Please note: MSDynamics API does not return proper display names for objects and fields,
-// so the ListObjectMetadataResult will have display names that look like "accountleads".
+// ListObjectMetadata returns list of fields that can be queried during Read operation.
+// The searched object will be considered not found by returning ErrObjectNotFound error.
+// This will happen if API calls for schema/attributes fail.
+// In an unlikely event it may happen if MS server's response format would change.
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
+	// enforce string formating, then internal delegation
 	return c.listObjectMetadata(ctx, naming.NewSingularStrings(objectNames))
 }
 
+// Internal delegate. EntityDefinitions API uses single object names.
 func (c *Connector) listObjectMetadata(
 	ctx context.Context, objectNames naming.SingularStrings,
 ) (*common.ListObjectMetadataResult, error) {
@@ -34,109 +27,31 @@ func (c *Connector) listObjectMetadata(
 		return nil, common.ErrMissingObjects
 	}
 
-	url, err := c.getURL("$metadata")
-	if err != nil {
-		return nil, err
-	}
-
-	rsp, err := c.XMLClient.Get(ctx, url.String())
-	if err != nil {
-		return nil, err
-	}
-
-	entities, err := extractEntities(rsp.Body)
-	if err != nil {
-		return nil, err
-	}
-
-	result, err := convertEntitySetToMetadataSet(objectNames, entities)
-	if err != nil {
-		return nil, err
-	}
-
-	return &common.ListObjectMetadataResult{
-		Result: result,
-		Errors: nil,
-	}, nil
-}
-
-// collects field properties and groups them in entities, other data in XML is ignored.
-func extractEntities(root *xquery.XML) (EntitySet, error) {
-	querySchema := fmt.Sprintf("//edmx:DataServices/Schema[@Namespace='%v']", CRMMetadataSchemaName)
-
-	schema := root.FindOne(querySchema)
-	if schema.IsEmpty() {
-		return nil, ErrMissingSchema
-	}
-
-	entities := NewEntitySet()
-	// List all field properties that exist for current schema
-	queryListAllSchemaProperties := fmt.Sprintf(
-		"//edmx:DataServices/Schema[@Namespace='%v']/EntityType[*]/Property/@Name", CRMMetadataSchemaName)
-	for _, nameAttr := range root.FindMany(queryListAllSchemaProperties) {
-		// parent of a property is an Entity.
-		// Entity may inherit properties from a parent
-		// We save entity name and the name of its parent, so later we can infer all properties by denormalisation
-		property := nameAttr.Parent()
-		entityObj := property.Parent()
-		entityName := entityObj.Attr("Name")
-		parentName := entityObj.Attr("BaseType")
-		entity := entities.GetOrCreate(entityName, parentName)
-		propertyName := property.Attr("Name")
-		entity.AddProperty(propertyName)
-	}
-
-	queryListAbstractEntities := fmt.Sprintf(
-		"//edmx:DataServices/Schema[@Namespace='%v']/EntityType[@Abstract='true']", CRMMetadataSchemaName)
-	for _, abstractEntity := range root.FindMany(queryListAbstractEntities) {
-		if !abstractEntity.HasChildren() {
-			// these entities were not included by previous query as they have no properties
-			// we programmatically find these special types, which are "primary values" but for structs
-			// Ex: crmbaseentity, crmmodelbaseentity,
-			entityName := abstractEntity.Attr("Name")
-			// effectively only create
-			_ = entities.GetOrCreate(entityName, "")
-		}
-	}
-
-	// link every child with parent completing hierarchy
-	schemaAlias := schema.Attr("Alias")
-	if err := entities.MatchParentsWithChildren(schemaAlias); err != nil {
-		return nil, errors.Join(ErrMetadataProcessing, err)
-	}
-
-	return entities, nil
-}
-
-// Select entities that match entity names of interest.
-// Every property has display identical to itself.
-func convertEntitySetToMetadataSet(
-	names naming.SingularStrings, entities EntitySet,
-) (map[string]common.ObjectMetadata, error) {
+	// Collect display name for the Object and its fields (name+display)
 	result := map[string]common.ObjectMetadata{}
 
-	for _, name := range names {
-		nameStrSingle := name.String()
-
-		entity, ok := entities[nameStrSingle]
-
-		if !ok {
-			return nil, fmt.Errorf("unknown entity %v %w", nameStrSingle, ErrObjectNotFound)
+	for _, objectName := range objectNames {
+		objectDisplayName, err := c.getObjectDisplayName(ctx, objectName)
+		if err != nil {
+			return nil, err
 		}
 
-		properties := entity.GetAllProperties()
-		fieldsMap := make(map[string]string)
-
-		for _, p := range properties {
-			fieldsMap[p] = p
+		fields, err := c.getFieldsForObject(ctx, objectName)
+		if err != nil {
+			return nil, err
 		}
 
-		nameStrPlural := name.Plural().String()
-		result[nameStrPlural] = common.ObjectMetadata{
-			DisplayName: nameStrPlural,
-			FieldsMap:   fieldsMap,
+		// Object names must be in plural.
+		// Connectors Read/Write methods for MS Dynamics use plural form. Ex: Read('contacts')
+		// The expectation is therefore to match, while schema API uses singular. Ex: `contact` schema
+		result[objectName.Plural().String()] = common.ObjectMetadata{
+			DisplayName: objectDisplayName,
+			FieldsMap:   fields,
 		}
 	}
 
-	return result, nil
+	return &common.ListObjectMetadataResult{
+		Result: result,
+		Errors: nil,
+	}, nil
 }
diff --git a/dynamicscrm/models.go b/dynamicscrm/models.go
deleted file mode 100644
index b687ca6d..00000000
--- a/dynamicscrm/models.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package dynamicscrm
-
-import (
-	"errors"
-	"strings"
-)
-
-var ErrInvalidParentRef = errors.New("referenced parent type doesn't exist for schema")
-
-// EntitySet organised by Entity.Name.
-type EntitySet map[string]*Entity
-
-func NewEntitySet() EntitySet {
-	return make(map[string]*Entity)
-}
-
-func (s EntitySet) GetNames() []string {
-	names := make([]string, len(s))
-	i := 0
-
-	for name := range s {
-		names[i] = name
-		i++
-	}
-
-	return names
-}
-
-func (s EntitySet) GetOrCreate(name, parentName string) *Entity {
-	if _, ok := s[name]; !ok {
-		s[name] = &Entity{
-			Name:       name,
-			properties: make([]string, 0),
-			parentName: parentName,
-		}
-	}
-
-	return s[name]
-}
-
-// Entity represents schema of the data returned by Microsoft
-// fields are inherited from parents so there is a tree hierarchy that can be traversed.
-type Entity struct {
-	Name       string
-	properties []string
-	parentName string
-	parent     *Entity
-}
-
-func (e *Entity) AddProperty(property string) {
-	e.properties = append(e.properties, property)
-}
-
-// GetRawParentName parents that are defined under schema are prefixed with its alias
-// this strips the prefix.
-func (e *Entity) GetRawParentName(schemaAlias string) string {
-	// strip prefix if we are using local schema
-	name, _ := strings.CutPrefix(e.parentName, schemaAlias+".")
-
-	return name
-}
-
-// MatchParentsWithChildren this will link a parent entity for everybody
-// only top root entities will have parent null.
-func (s EntitySet) MatchParentsWithChildren(schemaAlias string) error {
-	for _, entity := range s {
-		name := entity.GetRawParentName(schemaAlias)
-		// if it has parent then match
-		if len(name) != 0 {
-			parent, ok := s[name]
-			if !ok {
-				return ErrInvalidParentRef
-			}
-
-			entity.parent = parent
-		}
-	}
-
-	return nil
-}
-
-// GetAllProperties recursive function that includes inherited fields from parents.
-func (e *Entity) GetAllProperties() []string {
-	if e.parent == nil {
-		// this is root
-		return e.properties
-	}
-
-	parentProperties := e.parent.GetAllProperties()
-
-	return append(e.properties, parentProperties...)
-}
diff --git a/dynamicscrm/parse.go b/dynamicscrm/parse.go
index c99be6bb..312f7008 100644
--- a/dynamicscrm/parse.go
+++ b/dynamicscrm/parse.go
@@ -23,7 +23,7 @@ func getNextRecordsURL(node *ajson.Node) (string, error) {
 	return jsonquery.New(node).StrWithDefault("@odata.nextLink", "")
 }
 
-func getMarshalledData(records []map[string]interface{}, fields []string) ([]common.ReadResultRow, error) {
+func getMarshalledData(records []map[string]any, fields []string) ([]common.ReadResultRow, error) {
 	data := make([]common.ReadResultRow, len(records))
 
 	for i, record := range records {
diff --git a/dynamicscrm/read.go b/dynamicscrm/read.go
index a1f9c317..fabda608 100644
--- a/dynamicscrm/read.go
+++ b/dynamicscrm/read.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/spyzhov/ajson"
 )
 
 // nolint:lll
@@ -63,3 +64,17 @@ func newPaginationHeader(pageSize int) common.Header {
 		Value: fmt.Sprintf("odata.maxpagesize=%v", pageSize),
 	}
 }
+
+// Internal GET request, where we expect JSON payload.
+func (c *Connector) performGetRequest(ctx context.Context, url *urlbuilder.URL) (*ajson.Node, error) {
+	rsp, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	if rsp.Body == nil {
+		return nil, ErrObjectNotFound
+	}
+
+	return rsp.Body, nil
+}


Test patch:
diff --git a/dynamicscrm/metadata_test.go b/dynamicscrm/metadata_test.go
index 5aaa66c2..ee7e60af 100644
--- a/dynamicscrm/metadata_test.go
+++ b/dynamicscrm/metadata_test.go
@@ -19,7 +19,9 @@ import (
 func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 	t.Parallel()
 
-	fakeServerResp := testutils.DataFromFile(t, "metadata.xml")
+	responseContactsSchema := testutils.DataFromFile(t, "contacts-schema.json")
+	// Attributes file is a shorter form of real Microsoft server response.
+	responseContactsAttributes := testutils.DataFromFile(t, "contacts-attributes.json")
 
 	tests := []struct {
 		name         string
@@ -40,90 +42,89 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 		},
 		{
 			name:  "Mime response header expected",
-			input: []string{"msfp_surveyinvite"},
+			input: []string{"accounts"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.WriteHeader(http.StatusTeapot)
 			})),
 			expectedErrs: []error{interpreter.ErrMissingContentType},
 		},
 		{
-			name:  "Missing XML response on status OK",
-			input: []string{"msfp_surveyinvite"},
+			name:  "Schema endpoint is not available for object",
+			input: []string{"butterflies"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				w.Header().Set("Content-Type", "application/xml")
+				w.Header().Set("Content-Type", "application/json")
 				w.WriteHeader(http.StatusOK)
-				mockutils.WriteBody(w, "")
+				_, _ = w.Write([]byte{})
 			})),
-			expectedErrs: []error{common.ErrNotXML},
+			expectedErrs: []error{ErrObjectNotFound},
 		},
 		{
-			name:  "Server response without CRM Schema",
-			input: []string{"msfp_surveyinvite"},
+			name:  "Attributes endpoint is not available for object",
+			input: []string{"butterflies"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				w.Header().Set("Content-Type", "application/xml")
+				w.Header().Set("Content-Type", "application/json")
 				w.WriteHeader(http.StatusOK)
-				mockutils.WriteBody(w, `
-				<?xml version="1.0" encoding="utf-8"?>
-				<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx"></edmx:Edmx>`)
+				switch path := r.URL.Path; {
+				case strings.HasSuffix(path, "EntityDefinitions(LogicalName='butterfly')"):
+					_, _ = w.Write(responseContactsSchema)
+				default:
+					_, _ = w.Write([]byte{})
+				}
 			})),
-			expectedErrs: []error{ErrMissingSchema},
+			expectedErrs: []error{ErrObjectNotFound},
 		},
 		{
-			name:  "Object name cannot be found from server response",
-			input: []string{"msfp_surveyinvite"},
+			name:  "Object doesn't have attributes",
+			input: []string{"accounts"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				w.Header().Set("Content-Type", "application/xml")
+				w.Header().Set("Content-Type", "application/json")
 				w.WriteHeader(http.StatusOK)
-				_, _ = w.Write(fakeServerResp)
+				switch path := r.URL.Path; {
+				case strings.HasSuffix(path, "EntityDefinitions(LogicalName='account')"):
+					_, _ = w.Write(responseContactsSchema)
+				case strings.HasSuffix(path, "EntityDefinitions(LogicalName='account')/Attributes"):
+					mockutils.WriteBody(w, `{"value":[]}`)
+				default:
+					_, _ = w.Write([]byte{})
+				}
 			})),
-			expectedErrs: []error{ErrObjectNotFound, errors.New("unknown entity msfp_surveyinvite")}, // nolint:goerr113
+			expectedErrs: []error{ErrObjectMissingAttributes},
 		},
 		{
 			name:  "Correctly list metadata for account leads and invite contact",
-			input: []string{"chats", "faxes"},
+			input: []string{"contacts"},
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				w.Header().Set("Content-Type", "application/xml")
+				w.Header().Set("Content-Type", "application/json")
 				w.WriteHeader(http.StatusOK)
-				_, _ = w.Write(fakeServerResp)
+				// server will be called 2 times
+				switch path := r.URL.Path; {
+				case strings.HasSuffix(path, "EntityDefinitions(LogicalName='contact')"):
+					_, _ = w.Write(responseContactsSchema)
+				case strings.HasSuffix(path, "EntityDefinitions(LogicalName='contact')/Attributes"):
+					_, _ = w.Write(responseContactsAttributes)
+				default:
+					_, _ = w.Write([]byte{})
+				}
 			})),
 			comparator: func(baseURL string, actual, expected *common.ListObjectMetadataResult) bool {
 				return mockutils.MetadataResultComparator.SubsetFields(actual, expected)
 			},
 			expected: &common.ListObjectMetadataResult{
 				Result: map[string]common.ObjectMetadata{
-					"faxes": {
-						DisplayName: "faxes",
-						FieldsMap: map[string]string{
-							"tsid":                 "tsid",
-							"numberofpages":        "numberofpages",
-							"coverpagename":        "coverpagename",
-							"overriddencreatedon":  "overriddencreatedon",
-							"subcategory":          "subcategory",
-							"billingcode":          "billingcode",
-							"subscriptionid":       "subscriptionid",
-							"importsequencenumber": "importsequencenumber",
-							"directioncode":        "directioncode",
-							"faxnumber":            "faxnumber",
-							"category":             "category",
-						},
-					},
-					"chats": {
-						DisplayName: "chats",
+					"contacts": {
+						DisplayName: "contacts",
 						FieldsMap: map[string]string{
-							"modifiedinteamson":                "modifiedinteamson",
-							"_linkedby_value":                  "_linkedby_value",
-							"_unlinkedby_value":                "_unlinkedby_value",
-							"teamschatid":                      "teamschatid",
-							"eventssummary":                    "eventssummary",
-							"importsequencenumber":             "importsequencenumber",
-							"overriddencreatedon":              "overriddencreatedon",
-							"linkedon":                         "linkedon",
-							"unlinkedon":                       "unlinkedon",
-							"lastsyncerror":                    "lastsyncerror",
-							"modifiedinteamsbyactivitypartyid": "modifiedinteamsbyactivitypartyid",
-							"syncstatus":                       "syncstatus",
-							"formattedscheduledstart":          "formattedscheduledstart",
-							"statuscode":                       "statuscode",
+							// nice display names
+							"adx_publicprofilecopy":    "Public Profile Copy",
+							"adx_identity_newpassword": "New Password Input",
+							"department":               "Department",
+							"shippingmethodcode":       "Shipping Method",
+							"lastname":                 "Last Name",
+							// schema name was used for display
+							"leadsourcecodename": "LeadSourceCodeName",
+							// underscore prefixed fields
+							"_accountid_value": "Account",
+							"_createdby_value": "Created By",
 						},
 					},
 				},
@@ -131,28 +132,6 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			},
 			expectedErrs: nil,
 		},
-		{
-			// In total phonecall will have 65 fields, where
-			// phonecall 		(has 7 fields) and inherits from
-			// activitypointer 	(has 58 fields), which in turn inherits from
-			// crmbaseentity 	(has 0 fields)
-			name:  "Correctly list metadata for phone calls including inherited fields",
-			input: []string{"phonecalls", "activitypointers", "crmbaseentities"},
-			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				w.Header().Set("Content-Type", "application/xml")
-				w.WriteHeader(http.StatusOK)
-				_, _ = w.Write(fakeServerResp)
-			})),
-			comparator: func(baseURL string, actual, expected *common.ListObjectMetadataResult) bool {
-				// we are comparing if number of fields match under ListObjectMetadataResult.Result
-				return compareFieldCount(map[string]int{
-					"phonecalls":       65,
-					"activitypointers": 58,
-					"crmbaseentities":  0,
-				}, actual)
-			},
-			expectedErrs: nil,
-		},
 	}
 
 	for _, tt := range tests {
@@ -210,18 +189,3 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 		})
 	}
 }
-
-func compareFieldCount(expectedFieldsCount map[string]int, actual *common.ListObjectMetadataResult) bool {
-	for entityName, count := range expectedFieldsCount {
-		if entity, ok := actual.Result[entityName]; !ok {
-			return false
-		} else {
-			got := len(entity.FieldsMap)
-			if got != count {
-				return false
-			}
-		}
-	}
-
-	return true
-}
diff --git a/dynamicscrm/read_test.go b/dynamicscrm/read_test.go
index dd3eb865..c55e80d1 100644
--- a/dynamicscrm/read_test.go
+++ b/dynamicscrm/read_test.go
@@ -20,7 +20,7 @@ import (
 func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 	t.Parallel()
 
-	fakeServerResp := testutils.DataFromFile(t, "read.json")
+	responseContactsGet := testutils.DataFromFile(t, "contacts-read.json")
 
 	tests := []struct {
 		name         string
@@ -95,7 +95,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 			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)
+				_, _ = w.Write(responseContactsGet)
 			})),
 			expected: &common.ReadResult{
 				Rows: 2,
@@ -135,7 +135,7 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop
 			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)
+				_, _ = w.Write(responseContactsGet)
 			})),
 			expected: &common.ReadResult{
 				Rows: 2,
diff --git a/dynamicscrm/test/contacts-attributes.json b/dynamicscrm/test/contacts-attributes.json
new file mode 100644
index 00000000..14398cf3
--- /dev/null
+++ b/dynamicscrm/test/contacts-attributes.json
@@ -0,0 +1,188 @@
+{
+  "@odata.context": "https://org5a07f924.api.crm.dynamics.com/api/data/v9.2/$metadata#EntityDefinitions('contact')/Attributes(LogicalName,DisplayName,SchemaName)",
+  "value": [
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.MemoAttributeMetadata",
+      "LogicalName": "adx_publicprofilecopy",
+      "SchemaName": "adx_PublicProfileCopy",
+      "MetadataId": "0a7a21ff-c47a-4bfb-9a03-f6bfe18a24bc",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Public Profile Copy",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "a9580c8f-7db1-4ef5-9ed3-514ea9685d06",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "Public Profile Copy",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "a9580c8f-7db1-4ef5-9ed3-514ea9685d06",
+          "HasChanged": null
+        }
+      }
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata",
+      "LogicalName": "adx_identity_newpassword",
+      "SchemaName": "adx_identity_newpassword",
+      "MetadataId": "543d8478-64b8-41a1-b4f6-3cb9d51ba048",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "New Password Input",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "e36e144b-6a66-4cb5-8560-a1eb746d4b61",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "New Password Input",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "e36e144b-6a66-4cb5-8560-a1eb746d4b61",
+          "HasChanged": null
+        }
+      }
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata",
+      "LogicalName": "department",
+      "SchemaName": "Department",
+      "MetadataId": "b941c517-1976-41fe-b8a1-4d5700d2325c",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Department",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "4826e7d6-2241-db11-898a-0007e9e17ebd",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "Department",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "4826e7d6-2241-db11-898a-0007e9e17ebd",
+          "HasChanged": null
+        }
+      }
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.PicklistAttributeMetadata",
+      "LogicalName": "shippingmethodcode",
+      "SchemaName": "ShippingMethodCode",
+      "MetadataId": "ff7d514a-d8ac-41b5-931c-c344aac35849",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Shipping Method",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "efcee1dc-2241-db11-898a-0007e9e17ebd",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "Shipping Method",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "efcee1dc-2241-db11-898a-0007e9e17ebd",
+          "HasChanged": null
+        }
+      }
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.StringAttributeMetadata",
+      "LogicalName": "lastname",
+      "SchemaName": "LastName",
+      "MetadataId": "2f8fdd79-0372-490d-9053-b743becfca0d",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Last Name",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "73e0eed0-2241-db11-898a-0007e9e17ebd",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "Last Name",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "73e0eed0-2241-db11-898a-0007e9e17ebd",
+          "HasChanged": null
+        }
+      }
+    },
+    {
+      "LogicalName": "leadsourcecodename",
+      "SchemaName": "LeadSourceCodeName",
+      "MetadataId": "e118e346-60e7-44dc-8edb-e1511eee302e",
+      "DisplayName": {
+        "LocalizedLabels": [],
+        "UserLocalizedLabel": null
+      }
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.LookupAttributeMetadata",
+      "Targets": [
+        "account"
+      ],
+      "LogicalName": "accountid",
+      "SchemaName": "AccountId",
+      "MetadataId": "47dd42e6-fef1-401b-9a1f-e285b5d9e84b",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Account",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "6043781e-6d2c-4ac6-b0a6-dcbb57b103aa",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "Account",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "6043781e-6d2c-4ac6-b0a6-dcbb57b103aa",
+          "HasChanged": null
+        }
+      }
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.LookupAttributeMetadata",
+      "Targets": [
+        "systemuser"
+      ],
+      "LogicalName": "createdby",
+      "SchemaName": "CreatedBy",
+      "MetadataId": "570f0d59-4827-4710-aea7-01bedcaa2f57",
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Created By",
+            "LanguageCode": 1033,
+            "IsManaged": true,
+            "MetadataId": "52d6a218-2341-db11-898a-0007e9e17ebd",
+            "HasChanged": null
+          }
+        ],
+        "UserLocalizedLabel": {
+          "Label": "Created By",
+          "LanguageCode": 1033,
+          "IsManaged": true,
+          "MetadataId": "52d6a218-2341-db11-898a-0007e9e17ebd",
+          "HasChanged": null
+        }
+      }
+    }
+  ]
+}
diff --git a/dynamicscrm/test/read.json b/dynamicscrm/test/contacts-read.json
similarity index 100%
rename from dynamicscrm/test/read.json
rename to dynamicscrm/test/contacts-read.json
diff --git a/dynamicscrm/test/contacts-schema.json b/dynamicscrm/test/contacts-schema.json
new file mode 100644
index 00000000..4283ab8a
--- /dev/null
+++ b/dynamicscrm/test/contacts-schema.json
@@ -0,0 +1,22 @@
+{
+  "@odata.context": "https://org5a07f924.api.crm.dynamics.com/api/data/v9.2/$metadata#EntityDefinitions(DisplayCollectionName)/$entity",
+  "MetadataId": "608861bc-50a4-4c5f-a02c-21fe1943e2cf",
+  "DisplayCollectionName": {
+    "LocalizedLabels": [
+      {
+        "Label": "Contacts",
+        "LanguageCode": 1033,
+        "IsManaged": true,
+        "MetadataId": "bb9709b3-2241-db11-898a-0007e9e17ebd",
+        "HasChanged": null
+      }
+    ],
+    "UserLocalizedLabel": {
+      "Label": "Contacts",
+      "LanguageCode": 1033,
+      "IsManaged": true,
+      "MetadataId": "bb9709b3-2241-db11-898a-0007e9e17ebd",
+      "HasChanged": null
+    }
+  }
+}
diff --git a/dynamicscrm/test/metadata.xml b/dynamicscrm/test/metadata.xml
deleted file mode 100644
index e0904a40..00000000
--- a/dynamicscrm/test/metadata.xml
+++ /dev/null
@@ -1,128 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
-    <edmx:Reference Uri="https://vocabularies.odata.org/OData.Community.Keys.V1.xml">
-        <edmx:Include Namespace="OData.Community.Keys.V1" Alias="Keys"/>
-        <edmx:IncludeAnnotations TermNamespace="OData.Community.Keys.V1"/>
-    </edmx:Reference>
-    <edmx:Reference Uri="https://vocabularies.odata.org/OData.Community.Display.V1.xml">
-        <edmx:Include Namespace="OData.Community.Display.V1" Alias="Display"/>
-        <edmx:IncludeAnnotations TermNamespace="OData.Community.Display.V1"/>
-    </edmx:Reference>
-    <edmx:DataServices>
-        <Schema Namespace="Microsoft.Dynamics.CRM" Alias="mscrm" xmlns="http://docs.oasis-open.org/odata/ns/edm">
-            <EntityType Name="crmbaseentity" Abstract="true"/>
-            <EntityType Name="phonecall" BaseType="mscrm.activitypointer">
-                <Key>
-                    <PropertyRef Name="activityid"/>
-                </Key>
-                <Property Name="subcategory" Type="Edm.String" Unicode="false"/>
-                <Property Name="directioncode" Type="Edm.Boolean"/>
-                <Property Name="overriddencreatedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="category" Type="Edm.String" Unicode="false"/>
-                <Property Name="subscriptionid" Type="Edm.Guid"/>
-                <Property Name="phonenumber" Type="Edm.String" Unicode="false"/>
-                <Property Name="importsequencenumber" Type="Edm.Int32"/>
-                <!-- Navigation properties are omitted -->
-            </EntityType>
-            <EntityType Name="activitypointer" BaseType="mscrm.crmbaseentity" Abstract="true">
-                <Key>
-                    <PropertyRef Name="activityid"/>
-                </Key>
-                <Property Name="_transactioncurrencyid_value" Type="Edm.Guid"/>
-                <Property Name="instancetypecode" Type="Edm.Int32"/>
-                <Property Name="_modifiedby_value" Type="Edm.Guid"/>
-                <Property Name="_createdby_value" Type="Edm.Guid"/>
-                <Property Name="onholdtime" Type="Edm.Int32"/>
-                <Property Name="description" Type="Edm.String" Unicode="false"/>
-                <Property Name="seriesid" Type="Edm.Guid"/>
-                <Property Name="_ownerid_value" Type="Edm.Guid"/>
-                <Property Name="lastonholdtime" Type="Edm.DateTimeOffset"/>
-                <Property Name="_owningteam_value" Type="Edm.Guid"/>
-                <Property Name="activitytypecode" Type="Edm.String" Unicode="false"/>
-                <Property Name="deliverylastattemptedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="exchangerate" Type="Edm.Decimal" Scale="Variable"/>
-                <Property Name="isbilled" Type="Edm.Boolean"/>
-                <Property Name="leftvoicemail" Type="Edm.Boolean"/>
-                <Property Name="postponeactivityprocessinguntil" Type="Edm.DateTimeOffset"/>
-                <Property Name="_owningbusinessunit_value" Type="Edm.Guid"/>
-                <Property Name="traversedpath" Type="Edm.String" Unicode="false"/>
-                <Property Name="scheduledstart" Type="Edm.DateTimeOffset"/>
-                <Property Name="descriptionblobid" Type="Edm.Binary"/>
-                <Property Name="activityadditionalparams" Type="Edm.String" Unicode="false"/>
-                <Property Name="subject" Type="Edm.String" Unicode="false"/>
-                <Property Name="scheduleddurationminutes" Type="Edm.Int32"/>
-                <Property Name="_serviceid_value" Type="Edm.Guid"/>
-                <Property Name="versionnumber" Type="Edm.Int64"/>
-                <Property Name="community" Type="Edm.Int32"/>
-                <Property Name="_createdonbehalfby_value" Type="Edm.Guid"/>
-                <Property Name="_slainvokedid_value" Type="Edm.Guid"/>
-                <Property Name="_regardingobjectid_value" Type="Edm.Guid"/>
-                <Property Name="actualdurationminutes" Type="Edm.Int32"/>
-                <Property Name="exchangeweblink" Type="Edm.String" Unicode="false"/>
-                <Property Name="senton" Type="Edm.DateTimeOffset"/>
-                <Property Name="isworkflowcreated" Type="Edm.Boolean"/>
-                <Property Name="scheduledend" Type="Edm.DateTimeOffset"/>
-                <Property Name="isregularactivity" Type="Edm.Boolean"/>
-                <Property Name="_slaid_value" Type="Edm.Guid"/>
-                <Property Name="_owninguser_value" Type="Edm.Guid"/>
-                <Property Name="ismapiprivate" Type="Edm.Boolean"/>
-                <Property Name="activityid" Type="Edm.Guid"/>
-                <Property Name="formattedscheduledend" Type="Edm.DateTimeOffset"/>
-                <Property Name="exchangeitemid" Type="Edm.String" Unicode="false"/>
-                <Property Name="processid" Type="Edm.Guid"/>
-                <Property Name="_sendermailboxid_value" Type="Edm.Guid"/>
-                <Property Name="stageid" Type="Edm.Guid"/>
-                <Property Name="modifiedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="createdon" Type="Edm.DateTimeOffset"/>
-                <Property Name="formattedscheduledstart" Type="Edm.DateTimeOffset"/>
-                <Property Name="statuscode" Type="Edm.Int32"/>
-                <Property Name="statecode" Type="Edm.Int32"/>
-                <Property Name="actualend" Type="Edm.DateTimeOffset"/>
-                <Property Name="_modifiedonbehalfby_value" Type="Edm.Guid"/>
-                <Property Name="actualstart" Type="Edm.DateTimeOffset"/>
-                <Property Name="prioritycode" Type="Edm.Int32"/>
-                <Property Name="descriptionblobid_name" Type="Edm.String" Unicode="false"/>
-                <Property Name="deliveryprioritycode" Type="Edm.Int32"/>
-                <Property Name="sortdate" Type="Edm.DateTimeOffset"/>
-                <Property Name="timezoneruleversionnumber" Type="Edm.Int32"/>
-                <Property Name="utcconversiontimezonecode" Type="Edm.Int32"/>
-                <!-- Navigation properties are omitted -->
-            </EntityType>
-            <EntityType Name="chat" BaseType="mscrm.activitypointer">
-                <Key>
-                    <PropertyRef Name="activityid"/>
-                </Key>
-                <Property Name="modifiedinteamson" Type="Edm.DateTimeOffset"/>
-                <Property Name="_linkedby_value" Type="Edm.Guid"/>
-                <Property Name="_unlinkedby_value" Type="Edm.Guid"/>
-                <Property Name="teamschatid" Type="Edm.String" Unicode="false"/>
-                <Property Name="eventssummary" Type="Edm.String" Unicode="false"/>
-                <Property Name="importsequencenumber" Type="Edm.Int32"/>
-                <Property Name="overriddencreatedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="linkedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="unlinkedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="lastsyncerror" Type="Edm.Int32"/>
-                <Property Name="modifiedinteamsbyactivitypartyid" Type="Edm.Guid"/>
-                <Property Name="syncstatus" Type="Edm.Int32"/>
-                <!-- Navigation properties are omitted -->
-            </EntityType>
-            <EntityType Name="fax" BaseType="mscrm.activitypointer">
-                <Key>
-                    <PropertyRef Name="activityid"/>
-                </Key>
-                <Property Name="tsid" Type="Edm.String" Unicode="false"/>
-                <Property Name="numberofpages" Type="Edm.Int32"/>
-                <Property Name="coverpagename" Type="Edm.String" Unicode="false"/>
-                <Property Name="overriddencreatedon" Type="Edm.DateTimeOffset"/>
-                <Property Name="subcategory" Type="Edm.String" Unicode="false"/>
-                <Property Name="billingcode" Type="Edm.String" Unicode="false"/>
-                <Property Name="subscriptionid" Type="Edm.Guid"/>
-                <Property Name="importsequencenumber" Type="Edm.Int32"/>
-                <Property Name="directioncode" Type="Edm.Boolean"/>
-                <Property Name="faxnumber" Type="Edm.String" Unicode="false"/>
-                <Property Name="category" Type="Edm.String" Unicode="false"/>
-                <!-- Navigation properties are omitted -->
-            </EntityType>
-        </Schema>
-    </edmx:DataServices>
-</edmx:Edmx>
diff --git a/test/dynamicscrm/metadata/main.go b/test/dynamicscrm/metadata/main.go
index bd482d84..d5c0985c 100644
--- a/test/dynamicscrm/metadata/main.go
+++ b/test/dynamicscrm/metadata/main.go
@@ -4,18 +4,16 @@ import (
 	"context"
 	"fmt"
 	"os/signal"
-	"strings"
 	"syscall"
-	"time"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/handy"
 	connTest "github.com/amp-labs/connectors/test/dynamicscrm"
 	"github.com/amp-labs/connectors/test/utils"
-	"github.com/amp-labs/connectors/test/utils/mockutils"
 )
 
 var (
-	objectName = "accounts"
+	objectName = "contacts"
 )
 
 // we want to compare fields returned by read and schema properties provided by metadata methods
@@ -31,19 +29,6 @@ func main() {
 	conn := connTest.GetMSDynamics365CRMConnector(ctx)
 	defer utils.Close(conn)
 
-	response, err := conn.Read(ctx, common.ReadParams{
-		ObjectName: objectName,
-	})
-	if err != nil {
-		utils.Fail("error reading from microsoft CRM", "error", err)
-	}
-
-	if response.Rows == 0 {
-		utils.Fail("expected to read at least one record", "error", err)
-	}
-
-	beforeCall := time.Now()
-
 	metadata, err := conn.ListObjectMetadata(ctx, []string{
 		objectName,
 	})
@@ -51,33 +36,28 @@ func main() {
 		utils.Fail("error listing metadata for microsoft CRM", "error", err)
 	}
 
-	fmt.Printf("ListObjectMetadata took %.2f seconds.\n", time.Since(beforeCall).Seconds())
+	fmt.Println("Read object using all fields from ListObjectMetadata")
 
-	fmt.Println("Compare object metadata against endpoint response:")
+	requestFields := handy.Map[string, string](metadata.Result[objectName].FieldsMap).Keys()
 
-	data := sanitizeReadResponse(response.Data[0].Raw)
-
-	mismatchErr := mockutils.ValidateReadConformsMetadata(objectName, data, metadata)
-	if mismatchErr != nil {
-		utils.Fail("schema and payload response have mismatching fields", "error", mismatchErr)
+	response, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: objectName,
+		Fields:     requestFields,
+	})
+	if err != nil {
+		utils.Fail("error reading from microsoft CRM", "error", err)
 	} else {
-		fmt.Println("==> success fields match.")
-	}
-}
-
-func sanitizeReadResponse(response map[string]any) map[string]any {
-	crucialFields := make(map[string]any)
+		if response.Rows == 0 {
+			utils.Fail("expected to read at least one record", "error", err)
+		}
 
-	for field, v := range response {
-		// ignore all fields that are OData annotations
-		// they are not part of ObjectMetadata
-		lower := strings.ToLower(field)
+		givenFields := handy.Map[string, any](response.Data[0].Fields).Keys()
 
-		isAnnotation := strings.Contains(lower, "@odata") || strings.Contains(lower, "@microsoft.dynamics.crm")
-		if !isAnnotation {
-			crucialFields[field] = v
+		difference := handy.NewSet(givenFields).Diff(handy.NewSet(requestFields))
+		if len(difference) != 0 {
+			utils.Fail("connector read didn't match requested fields", "difference", difference)
 		}
 	}
 
-	return crucialFields
+	fmt.Println("==> success fields requested from ListObjectMetadata are all present in Read.")
 }
diff --git a/test/salesforce/connector.go b/test/salesforce/connector.go
index 39ce0da9..ae05be42 100644
--- a/test/salesforce/connector.go
+++ b/test/salesforce/connector.go
@@ -3,14 +3,14 @@ package salesforce
 import (
 	"context"
 	"fmt"
-	"github.com/amp-labs/connectors/common/scanning/credscanning"
-	"github.com/amp-labs/connectors/providers"
-	"github.com/amp-labs/connectors/test/utils"
-	"golang.org/x/oauth2"
 	"net/http"
 
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
 	"github.com/amp-labs/connectors/salesforce"
+	"github.com/amp-labs/connectors/test/utils"
 	testUtils "github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
 )
 
 func GetSalesforceConnector(ctx context.Context) *salesforce.Connector {