Repository: amp-labs/connectors
Pull request #1294 (2025-01-21 22:07:58)

[ENG-1806] Support Hubspot beta webhook message
## Test Result

- Some Unit tests are included in this PR.

![Screenshot 2025-01-21 at 2 30 59 PM](https://github.com/user-attachments/assets/a4167f46-b2f8-48c6-8400-2eebf6855bf3)


```
time=2025-01-21T13:35:04.683-08:00 level=DEBUG msg="loading credentials file" path=./hubspot-creds.json
time=2025-01-21T13:35:04.685-08:00 level=DEBUG msg="HTTP request" connector=hubspot method=GET url=https://api.hubapi.com/crm-object-schemas/v3/schemas/contacts headers="[{Key:Accept Value:application/json}]"
Schema for contacts:  {
    "archived": false,
    "associations": [...omitted 1.3k lines...],
    "createdAt": "1970-01-01T00:00:00Z",
    "createdByUserId": null,
    "description": "Any person who interacts with a HubSpot user’s business.",
    "fullyQualifiedName": "CONTACT",
    "id": "1",
    "labels": {
        "plural": "Contacts",
        "singular": "Contact"
    },
    "metaType": "HUBSPOT",
    "name": "CONTACT",
    "objectTypeId": "0-1",
    "primaryDisplayProperty": "firstname",
    "properties": [...Omitted 15k lines...],
    "requiredProperties": [],
    "restorable": true,
    "searchableProperties": [
        "firstname",
        "hs_searchable_calculated_international_phone_number",
        "lastname",
        "hs_marketable_until_renewal",
        "hs_marketable_status",
        "hs_additional_emails",
        "phone",
        "mobilephone",
        "hs_searchable_calculated_phone_number",
        "hs_searchable_calculated_mobile_number",
        "hs_searchable_calculated_international_mobile_number",
        "company",
        "fax",
        "email"
    ],
    "secondaryDisplayProperties": [
        "lastname",
        "email",
        "phone"
    ],
    "updatedAt": "2024-08-20T20:53:48.562Z",
    "updatedByUserId": null
}
time=2025-01-21T13:35:05.463-08:00 level=DEBUG msg="HTTP request" connector=hubspot method=GET url=https://api.hubapi.com/crm-object-schemas/v3/schemas/0-1 headers="[{Key:Accept Value:application/json}]"
Schema for 0-1:  {
    "archived": false,
    "associations": [... omitted 1.3k lines...],
    "createdAt": "1970-01-01T00:00:00Z",
    "createdByUserId": null,
    "description": "Any person who interacts with a HubSpot user’s business.",
    "fullyQualifiedName": "CONTACT",
    "id": "1",
    "labels": {
        "plural": "Contacts",
        "singular": "Contact"
    },
    "metaType": "HUBSPOT",
    "name": "CONTACT",
    "objectTypeId": "0-1",
    "primaryDisplayProperty": "firstname",
    "properties": [... omitted 15k lines ...],
    "requiredProperties": [],
    "restorable": true,
    "searchableProperties": [
        "firstname",
        "hs_searchable_calculated_international_phone_number",
        "lastname",
        "hs_marketable_until_renewal",
        "hs_marketable_status",
        "hs_additional_emails",
        "phone",
        "mobilephone",
        "hs_searchable_calculated_phone_number",
        "hs_searchable_calculated_mobile_number",
        "hs_searchable_calculated_international_mobile_number",
        "company",
        "fax",
        "email"
    ],
    "secondaryDisplayProperties": [
        "lastname",
        "email",
        "phone"
    ],
    "updatedAt": "2024-08-20T20:53:48.562Z",
    "updatedByUserId": null
}

```





Patch:
diff --git a/common/types.go b/common/types.go
index ca283d9f..15633a82 100644
--- a/common/types.go
+++ b/common/types.go
@@ -279,6 +279,12 @@ func NewListObjectMetadataResult() *ListObjectMetadataResult {
 	}
 }
 
+// AppendError will associate an error with the object.
+// It is possible that single object may have multiple errors.
+func (r ListObjectMetadataResult) AppendError(objectName string, err error) {
+	r.Errors[objectName] = errors.Join(r.Errors[objectName], err)
+}
+
 type ObjectMetadata struct {
 	// Provider's display name for the object.
 	DisplayName string
diff --git a/internal/datautils/lists.go b/internal/datautils/lists.go
index 3077c93b..65648b64 100644
--- a/internal/datautils/lists.go
+++ b/internal/datautils/lists.go
@@ -68,3 +68,13 @@ func (l UniqueLists[ID, V]) GetBuckets() []ID {
 
 	return result
 }
+
+func (l IndexedLists[ID, V]) MergeWith(other IndexedLists[ID, V]) {
+	for k, v := range other {
+		l.Add(k, v...)
+	}
+}
+
+func (l NamedLists[V]) MergeWith(other NamedLists[V]) {
+	IndexedLists[string, V](l).MergeWith(IndexedLists[string, V](other))
+}
diff --git a/internal/staticschema/convertors.go b/internal/staticschema/convertors.go
index 42bb04a2..04363109 100644
--- a/internal/staticschema/convertors.go
+++ b/internal/staticschema/convertors.go
@@ -1,18 +1,14 @@
 package staticschema
 
 import (
-	"errors"
 	"fmt"
 
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/datautils"
 )
 
-var ErrObjectNotFound = errors.New("object not found")
-
 // Select will look for object names under the module and will return metadata result for those objects.
 // NOTE: empty module id is treated as root module.
-func (r *Metadata) Select(
+func (m *Metadata[F]) Select(
 	moduleID common.ModuleID, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
 	if len(objectNames) == 0 {
@@ -22,7 +18,7 @@ func (r *Metadata) Select(
 	moduleID = moduleIdentifier(moduleID)
 
 	// Convert and return only listed objects
-	module, ok := r.Modules[moduleID]
+	module, ok := m.Modules[moduleID]
 	if !ok {
 		return nil, fmt.Errorf("%w: connector is using unknown module [%v]", common.ErrMissingModule, moduleID)
 	}
@@ -36,12 +32,9 @@ func (r *Metadata) Select(
 	for _, objectName := range objectNames {
 		if v, ok := module.Objects[objectName]; ok {
 			// move metadata from scrapper object to common object
-			list.Result[objectName] = common.ObjectMetadata{
-				DisplayName: v.DisplayName,
-				FieldsMap:   datautils.FromMap(v.FieldsMap).ShallowCopy(),
-			}
+			list.Result[objectName] = *v.getObjectMetadata()
 		} else {
-			return nil, fmt.Errorf("%w: unknown object [%v]", ErrObjectNotFound, objectName)
+			return nil, fmt.Errorf("%w: unknown object [%v]", common.ErrObjectNotSupported, objectName)
 		}
 	}
 
@@ -50,29 +43,20 @@ func (r *Metadata) Select(
 
 // SelectOne reads one object metadata from the static file.
 // NOTE: empty module id is treated as root module.
-func (r *Metadata) SelectOne(
+func (m *Metadata[F]) SelectOne(
 	moduleID common.ModuleID, objectName string,
 ) (*common.ObjectMetadata, error) {
 	moduleID = moduleIdentifier(moduleID)
 
 	// Convert and return only listed objects
-	module, ok := r.Modules[moduleID]
+	module, ok := m.Modules[moduleID]
 	if !ok {
 		return nil, fmt.Errorf("%w: connector is using unknown module [%v]", common.ErrMissingModule, moduleID)
 	}
 
-	mtd := common.ObjectMetadata{
-		FieldsMap: make(map[string]string),
-	}
-
 	if v, ok := module.Objects[objectName]; ok {
-		mtd = common.ObjectMetadata{
-			DisplayName: v.DisplayName,
-			FieldsMap:   v.FieldsMap,
-		}
-	} else {
-		return nil, fmt.Errorf("%w: unknown object [%v]", ErrObjectNotFound, objectName)
+		return v.getObjectMetadata(), nil
 	}
 
-	return &mtd, nil
+	return nil, fmt.Errorf("%w: unknown object [%v]", common.ErrObjectNotSupported, objectName)
 }
diff --git a/internal/staticschema/core.go b/internal/staticschema/core.go
index dd058723..34e6fea8 100644
--- a/internal/staticschema/core.go
+++ b/internal/staticschema/core.go
@@ -10,9 +10,9 @@ import (
 const RootModuleID common.ModuleID = "root"
 
 // NewMetadata constructs empty Metadata. To populate it with data use Metadata.Add method.
-func NewMetadata() *Metadata {
-	return &Metadata{
-		Modules: make(map[common.ModuleID]Module),
+func NewMetadata[F FieldMetadataMap]() *Metadata[F] {
+	return &Metadata[F]{
+		Modules: make(map[common.ModuleID]Module[F]),
 	}
 }
 
@@ -22,18 +22,18 @@ func NewMetadata() *Metadata {
 // Metadata.ObjectNames - registry of object names by modules.
 // Metadata.LookupURLPath - resolves URL given object name.
 // Metadata.Select - produces output suitable for connectors.ObjectMetadataConnector.
-type Metadata struct {
+type Metadata[F FieldMetadataMap] struct {
 	// Modules is a map of object names to object metadata
-	Modules map[common.ModuleID]Module `json:"modules"`
+	Modules map[common.ModuleID]Module[F] `json:"modules"`
 }
 
-type Module struct {
-	ID      common.ModuleID   `json:"id"`
-	Path    string            `json:"path"`
-	Objects map[string]Object `json:"objects"`
+type Module[F FieldMetadataMap] struct {
+	ID      common.ModuleID      `json:"id"`
+	Path    string               `json:"path"`
+	Objects map[string]Object[F] `json:"objects"`
 }
 
-type Object struct {
+type Object[F FieldMetadataMap] struct {
 	// Provider's display name for the object
 	DisplayName string `json:"displayName"`
 
@@ -44,41 +44,119 @@ type Object struct {
 	// A field name where the object is located within provider response.
 	ResponseKey string `json:"responseKey"`
 
-	// FieldsMap is a map of field names to field display names
-	FieldsMap map[string]string `json:"fields"`
+	// Fields is a map of field names to either field display names or extended field metadata.
+	Fields F `json:"fields"`
 
 	// DocsURL points to docs endpoint. Optional.
 	DocsURL *string `json:"docs,omitempty"`
 }
 
+type (
+	FieldMetadataMapV1 map[string]string
+	FieldMetadataMapV2 map[string]common.FieldMetadata
+
+	FieldMetadataMap interface {
+		FieldMetadataMapV1 | FieldMetadataMapV2
+	}
+)
+
+// All maps must be copies of the original map, which remains immutable.
+// The immutable map serves as a unified registry of metadata for the connector.
+// ListObjectMetadata returns a copy that consumers are allowed to modify as needed.
+func (o Object[F]) getObjectMetadata() *common.ObjectMetadata {
+	if fieldsMap, isV1 := (any(o.Fields)).(FieldMetadataMapV1); isV1 {
+		return &common.ObjectMetadata{
+			DisplayName: o.DisplayName,
+			FieldsMap:   datautils.FromMap(fieldsMap).ShallowCopy(),
+		}
+	}
+
+	fields, isV2 := (any(o.Fields)).(FieldMetadataMapV2)
+	if !isV2 {
+		// Unknown fieldsMap version.
+		return &common.ObjectMetadata{}
+	}
+
+	return common.NewObjectMetadata(
+		o.DisplayName,
+		datautils.FromMap(fields).ShallowCopy(),
+	)
+}
+
 // Add will appropriately store the data, abiding to data structure rules.
 // NOTE: empty module id is treated as root module.
-func (r *Metadata) Add(
+func (m *Metadata[F]) Add( // nolint:funlen
 	moduleID common.ModuleID,
-	objectName, objectDisplayName, fieldName, urlPath, responseKey string,
+	objectName, objectDisplayName, urlPath, responseKey string,
+	fieldMetadataMap F,
 	docsURL *string,
 ) {
 	moduleID = moduleIdentifier(moduleID)
 
-	module := r.getOrCreateModule(moduleID)
+	module := m.getOrCreateModule(moduleID)
 
-	data, ok := module.Objects[objectName]
-	if !ok {
-		data = Object{
+	object, objectExists := module.Objects[objectName]
+	defer func() {
+		module.Objects[objectName] = object
+	}()
+
+	if !objectExists {
+		object = Object[F]{
 			DisplayName: objectDisplayName,
 			URLPath:     urlPath,
 			ResponseKey: responseKey,
-			FieldsMap:   make(map[string]string),
+			Fields:      fieldMetadataMap,
 			DocsURL:     docsURL,
 		}
-		module.Objects[objectName] = data
+
+		return
 	}
 
-	data.FieldsMap[fieldName] = fieldName
+	// This code acts as a bridge between a concrete type and a generic type.
+	//
+	// Key points:
+	// - The range operation is not supported for expressions without a core type.
+	//   Therefore, the map is type-asserted to its concrete type.
+	// - For the concrete type, old and new values are copied into a joined map of that type.
+	// - Finally, the map is converted back to the generic type.
+	//
+	// Note:
+	// - This is a workaround for limitations in Go generics.
+	// - During compilation, only one execution path is valid for each concrete type.
+	//   The other paths are unreachable and conceptually invalid, as they pertain to entirely different types.
+	if presentFields, isV1 := (any(object.Fields)).(FieldMetadataMapV1); isV1 {
+		fieldsMap := make(FieldMetadataMapV1)
+		for k, v := range presentFields {
+			fieldsMap[k] = v
+		}
+
+		for k, v := range (any(fieldMetadataMap)).(FieldMetadataMapV1) { // nolint:forcetypeassert
+			fieldsMap[k] = v
+		}
+
+		object.Fields = any(fieldsMap).(F) // nolint:forcetypeassert
+
+		return
+	}
+
+	if presentFields, isV2 := (any(object.Fields)).(FieldMetadataMapV2); isV2 {
+		fieldsMap := make(FieldMetadataMapV2)
+		for k, v := range presentFields {
+			fieldsMap[k] = v
+		}
+
+		for k, v := range (any(fieldMetadataMap)).(FieldMetadataMapV2) { // nolint:forcetypeassert
+			fieldsMap[k] = v
+		}
+
+		object.Fields = any(fieldsMap).(F) // nolint:forcetypeassert
+
+		return
+	}
 }
 
-func (r *Metadata) refactorLongestCommonPath() {
-	for moduleID, module := range r.Modules {
+func (m *Metadata[F]) refactorLongestCommonPath() {
+	for moduleID, module := range m.Modules {
 		var (
 			commonPath string
 			isFirst    = true
@@ -102,30 +180,30 @@ func (r *Metadata) refactorLongestCommonPath() {
 
 		// CommonPath is now found.
 		module.withPath(commonPath)
-		r.Modules[moduleID] = module
+		m.Modules[moduleID] = module
 	}
 }
 
-func (r *Metadata) getOrCreateModule(moduleID common.ModuleID) Module {
-	module, ok := r.Modules[moduleID]
+func (m *Metadata[F]) getOrCreateModule(moduleID common.ModuleID) Module[F] {
+	module, ok := m.Modules[moduleID]
 	if !ok {
 		// new module
-		module = Module{
+		module = Module[F]{
 			ID:      moduleID,
 			Path:    "",
-			Objects: make(map[string]Object),
+			Objects: make(map[string]Object[F]),
 		}
-		r.Modules[moduleID] = module
+		m.Modules[moduleID] = module
 	}
 
 	return module
 }
 
 // ObjectNames provides a registry of object names grouped by module.
-func (r *Metadata) ObjectNames() datautils.UniqueLists[common.ModuleID, string] {
+func (m *Metadata[F]) ObjectNames() datautils.UniqueLists[common.ModuleID, string] {
 	moduleObjectNames := make(datautils.UniqueLists[common.ModuleID, string])
 
-	for key, value := range r.Modules {
+	for key, value := range m.Modules {
 		names := datautils.NewStringSet()
 		for name := range value.Objects {
 			names.AddOne(name)
@@ -144,30 +222,30 @@ func (r *Metadata) ObjectNames() datautils.UniqueLists[common.ModuleID, string]
 
 // LookupURLPath will give you the URL path for the object located under the module.
 // NOTE: empty module id is treated as root module.
-func (r *Metadata) LookupURLPath(moduleID common.ModuleID, objectName string) (string, error) {
+func (m *Metadata[F]) LookupURLPath(moduleID common.ModuleID, objectName string) (string, error) {
 	moduleID = moduleIdentifier(moduleID)
 
-	path := r.Modules[moduleID].Objects[objectName].URLPath
+	path := m.Modules[moduleID].Objects[objectName].URLPath
 	if len(path) == 0 {
 		return "", common.ErrResolvingURLPathForObject
 	}
 
-	fullPath := r.LookupModuleURLPath(moduleID) + path
+	fullPath := m.LookupModuleURLPath(moduleID) + path
 
 	return fullPath, nil
 }
 
-func (r *Metadata) LookupModuleURLPath(moduleID common.ModuleID) string {
+func (m *Metadata[F]) LookupModuleURLPath(moduleID common.ModuleID) string {
 	moduleID = moduleIdentifier(moduleID)
 
-	return r.Modules[moduleID].Path
+	return m.Modules[moduleID].Path
 }
 
 // ModuleRegistry returns the list of API modules from static schema.
-func (r *Metadata) ModuleRegistry() common.Modules {
-	result := make(common.Modules, len(r.Modules))
+func (m *Metadata[F]) ModuleRegistry() common.Modules {
+	result := make(common.Modules, len(m.Modules))
 
-	for id, module := range r.Modules {
+	for id, module := range m.Modules {
 		// Label and version is not differentiated and all is part of path.
 		result[id] = common.Module{
 			ID:      module.ID,
@@ -181,15 +259,15 @@ func (r *Metadata) ModuleRegistry() common.Modules {
 
 // LookupArrayFieldName will give you the field name which holds the array of objects in provider response.
 // Ex: CustomerSubscriptions is located under field name subscriptions => { "subscriptions": [{},{},{}] }.
-func (r *Metadata) LookupArrayFieldName(moduleID common.ModuleID, objectName string) string {
+func (m *Metadata[F]) LookupArrayFieldName(moduleID common.ModuleID, objectName string) string {
 	moduleID = moduleIdentifier(moduleID)
 
-	fieldName := r.Modules[moduleID].Objects[objectName].ResponseKey
+	fieldName := m.Modules[moduleID].Objects[objectName].ResponseKey
 
 	return fieldName
 }
 
-func (m *Module) withPath(path string) {
+func (m *Module[F]) withPath(path string) {
 	// Move last slash from module path to object. It looks better that way.
 	path, _ = strings.CutSuffix(path, "/")
 
diff --git a/internal/staticschema/files.go b/internal/staticschema/files.go
index a1e21d06..ce16ae0b 100644
--- a/internal/staticschema/files.go
+++ b/internal/staticschema/files.go
@@ -9,35 +9,35 @@ import (
 const SchemasFile = "schemas.json"
 
 // FileManager operates on schema.json file.
-type FileManager struct {
+type FileManager[F FieldMetadataMap] struct {
 	schemas []byte
 	locator fileconv.FileLocator
 	flush   fileconv.Flusher
 }
 
-func NewFileManager(schemas []byte, locator fileconv.FileLocator) *FileManager {
-	return &FileManager{
+func NewFileManager[F FieldMetadataMap](schemas []byte, locator fileconv.FileLocator) *FileManager[F] {
+	return &FileManager[F]{
 		schemas: schemas,
 		locator: locator,
 	}
 }
 
 // SaveSchemas is useful method when creating or updating static schema file.
-func (m FileManager) SaveSchemas(schemas *Metadata) error {
+func (m FileManager[F]) SaveSchemas(schemas *Metadata[F]) error {
 	schemas.refactorLongestCommonPath()
 
 	return m.flush.ToFile(m.locator.AbsPathTo(SchemasFile), schemas)
 }
 
 // MustLoadSchemas parses static schema file and returns Metadata for deep connector.
-func (m FileManager) MustLoadSchemas() *Metadata {
-	var result *Metadata
+func (m FileManager[F]) MustLoadSchemas() *Metadata[F] {
+	var result *Metadata[F]
 
 	err := json.Unmarshal(m.schemas, &result)
 	if err != nil {
 		// This error should never occur if schemas file is of correct format.
 		// If at least one test exists for the connector this will be caught at development time.
-		return &Metadata{}
+		return &Metadata[F]{}
 	}
 
 	return result
diff --git a/providers/constantcontact/metadata/scraping.go b/providers/constantcontact/metadata/schemas.go
similarity index 64%
rename from providers/constantcontact/metadata/scraping.go
rename to providers/constantcontact/metadata/schemas.go
index 8e928f4b..c22a8d57 100644
--- a/providers/constantcontact/metadata/scraping.go
+++ b/providers/constantcontact/metadata/schemas.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/customerapp/metadata/scraping.go b/providers/customerapp/metadata/scraping.go
index 8e928f4b..c22a8d57 100644
--- a/providers/customerapp/metadata/scraping.go
+++ b/providers/customerapp/metadata/scraping.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/dynamicscrm/connector.go b/providers/dynamicscrm/connector.go
index bba12519..5da84194 100644
--- a/providers/dynamicscrm/connector.go
+++ b/providers/dynamicscrm/connector.go
@@ -1,11 +1,8 @@
 package dynamicscrm
 
 import (
-	"fmt"
-
 	"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"
@@ -14,8 +11,9 @@ import (
 const apiVersion = "v9.2"
 
 type Connector struct {
-	BaseURL string
-	Client  *common.JSONHTTPClient
+	BaseURL                     string
+	Client                      *common.JSONHTTPClient
+	metadataDiscoveryRepository metadataDiscoveryRepository
 }
 
 func NewConnector(opts ...Option) (conn *Connector, outErr error) {
@@ -41,6 +39,10 @@ func NewConnector(opts ...Option) (conn *Connector, outErr error) {
 	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
 		JSON: interpreter.NewFaultyResponder(errorFormats, nil),
 	}.Handle
+	conn.metadataDiscoveryRepository = metadataDiscoveryRepository{
+		client:   conn.Client,
+		buildURL: conn.getURL,
+	}
 
 	return conn, nil
 }
@@ -57,22 +59,6 @@ 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/providers/dynamicscrm/entityAttributes.go b/providers/dynamicscrm/entityAttributes.go
deleted file mode 100644
index 19137767..00000000
--- a/providers/dynamicscrm/entityAttributes.go
+++ /dev/null
@@ -1,139 +0,0 @@
-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/providers/dynamicscrm/entityDefinition.go b/providers/dynamicscrm/entityDefinition.go
deleted file mode 100644
index 21c339fe..00000000
--- a/providers/dynamicscrm/entityDefinition.go
+++ /dev/null
@@ -1,49 +0,0 @@
-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/providers/dynamicscrm/metadata.go b/providers/dynamicscrm/metadata.go
index d4ab1d99..3e61bce6 100644
--- a/providers/dynamicscrm/metadata.go
+++ b/providers/dynamicscrm/metadata.go
@@ -18,7 +18,7 @@ func (c *Connector) ListObjectMetadata(
 		return nil, common.ErrMissingObjects
 	}
 
-	// enforce string formating, then internal delegation
+	// enforce string formating, then do internal delegation
 	return c.listObjectMetadata(ctx, naming.NewSingularStrings(objectNames))
 }
 
@@ -26,35 +26,29 @@ func (c *Connector) ListObjectMetadata(
 func (c *Connector) listObjectMetadata(
 	ctx context.Context, objectNames naming.SingularStrings,
 ) (*common.ListObjectMetadataResult, error) {
-	if len(objectNames) == 0 {
-		return nil, common.ErrMissingObjects
-	}
-
 	// Collect display name for the Object and its fields (name+display)
-	result := map[string]common.ObjectMetadata{}
+	metadataResult := common.NewListObjectMetadataResult()
 
 	for _, objectName := range objectNames {
-		objectDisplayName, err := c.getObjectDisplayName(ctx, objectName)
+		metadataObjectName := objectName.Plural().String()
+
+		objectDisplayName, err := c.fetchObjectDisplayName(ctx, objectName)
 		if err != nil {
-			return nil, err
+			metadataResult.AppendError(metadataObjectName, err)
 		}
 
-		fields, err := c.getFieldsForObject(ctx, objectName)
+		fields, err := c.fetchFieldsForObject(ctx, objectName)
 		if err != nil {
-			return nil, err
+			metadataResult.AppendError(metadataObjectName, err)
 		}
 
 		// 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,
-		}
+		metadataResult.Result[metadataObjectName] = *common.NewObjectMetadata(
+			objectDisplayName, fields,
+		)
 	}
 
-	return &common.ListObjectMetadataResult{
-		Result: result,
-		Errors: nil,
-	}, nil
+	return metadataResult, nil
 }
diff --git a/providers/dynamicscrm/metadataRepository.go b/providers/dynamicscrm/metadataRepository.go
new file mode 100644
index 00000000..0e332da4
--- /dev/null
+++ b/providers/dynamicscrm/metadataRepository.go
@@ -0,0 +1,280 @@
+package dynamicscrm
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/internal/datautils"
+)
+
+var (
+	ErrFetchEntityDefinition    = errors.New("failed to fetch object's entity definition")
+	ErrFetchAttributes          = errors.New("failed to fetch object's attributes")
+	ErrFetchAttributesPicklists = errors.New("failed to fetch object's PicklistType attributes")
+	ErrFetchAttributesStatuses  = errors.New("failed to fetch object's StatusType attributes")
+	ErrFetchAttributesStates    = errors.New("failed to fetch object's StateType attributes")
+)
+
+// This repository is a grouping of Microsoft Dataverse API concerned with fetching Object metadata.
+//
+// It provides with the following data:
+// * entityDefinitionResponse - metadata of an object
+// * attributesResponse - complete list of attributes that exist on the object
+// Attributes with enumeration options grouped by attribute type:
+// * attributesPicklistsResponse.
+// * attributesStatusesResponse.
+// * attributesStatesResponse.
+type metadataDiscoveryRepository struct {
+	client   *common.JSONHTTPClient
+	buildURL func(path string) (*urlbuilder.URL, error)
+}
+
+func (r metadataDiscoveryRepository) fetchEntityDefinition(
+	ctx context.Context, objectName naming.SingularString,
+) (dao *entityDefinitionResponse, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("%w: %w", ErrFetchEntityDefinition, err)
+		}
+	}()
+	// This endpoint returns schema of an object.
+	// Schema name must be singular.
+	path := fmt.Sprintf("EntityDefinitions(LogicalName='%v')", objectName.String())
+
+	url, err := r.buildURL(path)
+	if err != nil {
+		return nil, err
+	}
+
+	// the only field we care about in response
+	url.WithQueryParam("$select", "DisplayCollectionName")
+
+	resp, err := r.performGetRequest(ctx, url)
+	if err != nil {
+		return nil, errors.Join(ErrObjectNotFound, err)
+	}
+
+	return common.UnmarshalJSON[entityDefinitionResponse](resp)
+}
+
+func (r metadataDiscoveryRepository) fetchAttributes(
+	ctx context.Context, objectName naming.SingularString,
+) (dao *attributesResponse, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("%w: %w", ErrFetchAttributes, err)
+		}
+	}()
+	// This endpoint will describe attributes present on schema and its properties.
+	// Schema name must be singular.
+	path := fmt.Sprintf("EntityDefinitions(LogicalName='%v')/Attributes", objectName.String())
+
+	url, err := r.buildURL(path)
+	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.
+
+	resp, err := r.performGetRequest(ctx, url)
+	if err != nil {
+		return nil, errors.Join(ErrObjectNotFound, err)
+	}
+
+	return common.UnmarshalJSON[attributesResponse](resp)
+}
+
+func (r metadataDiscoveryRepository) fetchAttributesPicklists(
+	ctx context.Context, objectName naming.SingularString,
+) (dao *attributesPicklistsResponse, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("%w: %w", ErrFetchAttributesPicklists, err)
+		}
+	}()
+
+	resp, err := r.getOptionsForAttribute(ctx, objectName, "Microsoft.Dynamics.CRM.PicklistAttributeMetadata")
+	if err != nil {
+		return nil, err
+	}
+
+	return common.UnmarshalJSON[attributesPicklistsResponse](resp)
+}
+
+func (r metadataDiscoveryRepository) fetchAttributesStatuses(
+	ctx context.Context, objectName naming.SingularString,
+) (dao *attributesStatusesResponse, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("%w: %w", ErrFetchAttributesStatuses, err)
+		}
+	}()
+
+	resp, err := r.getOptionsForAttribute(ctx, objectName, "Microsoft.Dynamics.CRM.StatusAttributeMetadata")
+	if err != nil {
+		return nil, err
+	}
+
+	return common.UnmarshalJSON[attributesStatusesResponse](resp)
+}
+
+func (r metadataDiscoveryRepository) fetchAttributesStates(
+	ctx context.Context, objectName naming.SingularString,
+) (dao *attributesStatesResponse, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("%w: %w", ErrFetchAttributesStates, err)
+		}
+	}()
+
+	resp, err := r.getOptionsForAttribute(ctx, objectName, "Microsoft.Dynamics.CRM.StateAttributeMetadata")
+	if err != nil {
+		return nil, err
+	}
+
+	return common.UnmarshalJSON[attributesStatesResponse](resp)
+}
+
+func (r metadataDiscoveryRepository) getOptionsForAttribute(
+	ctx context.Context, objectName naming.SingularString, attributeName string,
+) (*common.JSONHTTPResponse, error) {
+	// This endpoint will fetch options metadata for an attribute.
+	path := "EntityDefinitions(LogicalName='" + objectName.String() + "')" +
+		"/Attributes/" + attributeName
+
+	url, err := r.buildURL(path)
+	if err != nil {
+		return nil, err
+	}
+
+	// Request nested optionSet for each attribute of Picklist type for current object.
+	// Reference: https://community.dynamics.com/forums/thread/details/?threadid=37c94bd2-7703-471b-a726-bcfea2bfa776
+	url.WithQueryParam("$expand", "OptionSet($select=Options)")
+	// Logical name is used to identify the attribute we are dealing with.
+	url.WithQueryParam("$select", "LogicalName")
+
+	resp, err := r.performGetRequest(ctx, url)
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+func (r metadataDiscoveryRepository) performGetRequest(
+	ctx context.Context, url *urlbuilder.URL,
+) (*common.JSONHTTPResponse, error) {
+	rsp, err := r.client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	if _, ok := rsp.Body(); !ok {
+		return nil, common.ErrEmptyJSONHTTPResponse
+	}
+
+	return rsp, nil
+}
+
+// nolint:tagliatelle
+type entityDefinitionResponse struct {
+	DisplayCollectionName struct {
+		localizedLabels
+	} `json:"DisplayCollectionName"`
+}
+
+type attributesResponse struct {
+	Values []attributeItem `json:"value"`
+}
+
+// nolint:tagliatelle
+type attributeItem struct {
+	LogicalName       string   `json:"LogicalName"`
+	SchemaName        string   `json:"SchemaName"`
+	Targets           []string `json:"Targets"`
+	IsValidForCreate  bool     `json:"IsValidForCreate"`
+	IsValidForUpdate  bool     `json:"IsValidForUpdate"`
+	AttributeTypeName struct {
+		Value string `json:"Value"`
+	} `json:"AttributeTypeName"`
+	DisplayName struct {
+		localizedLabels
+	} `json:"DisplayName"`
+	Format string `json:"Format"`
+}
+
+// nolint:tagliatelle
+type localizedLabels struct {
+	LocalizedLabels []struct {
+		Label string `json:"Label"`
+	} `json:"LocalizedLabels"`
+}
+
+func (l localizedLabels) getName() (string, bool) {
+	labels := l.LocalizedLabels
+	if len(labels) == 0 {
+		return "", false
+	}
+
+	return labels[0].Label, true
+}
+
+type attributesPicklistsResponse struct {
+	attributesWithOptions
+}
+
+type attributesStatusesResponse struct {
+	attributesWithOptions
+}
+
+type attributesStatesResponse struct {
+	attributesWithOptions
+}
+
+// nolint:tagliatelle
+type attributesWithOptions struct {
+	Value []struct {
+		LogicalName string `json:"LogicalName"`
+		MetadataId  string `json:"MetadataId"`
+		OptionSet   struct {
+			MetadataId string `json:"MetadataId"`
+			Options    []struct {
+				Value    int  `json:"Value"`
+				IsHidden bool `json:"IsHidden"`
+				Label    struct {
+					localizedLabels
+				} `json:"Label"`
+			} `json:"Options"`
+		} `json:"OptionSet"`
+	} `json:"value"`
+}
+
+func (a attributesWithOptions) getOptionsPerAttribute() datautils.NamedLists[common.FieldValue] {
+	result := make(datautils.NamedLists[common.FieldValue])
+
+	for _, attribute := range a.Value {
+		for _, option := range attribute.OptionSet.Options {
+			if !option.IsHidden {
+				name, _ := option.Label.getName()
+				result.Add(attribute.LogicalName, common.FieldValue{
+					Value:        strconv.FormatInt(int64(option.Value), 10),
+					DisplayValue: name,
+				})
+			}
+		}
+	}
+
+	return result
+}
diff --git a/providers/dynamicscrm/metadataService.go b/providers/dynamicscrm/metadataService.go
new file mode 100644
index 00000000..41c5cc8d
--- /dev/null
+++ b/providers/dynamicscrm/metadataService.go
@@ -0,0 +1,200 @@
+package dynamicscrm
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/naming"
+	"github.com/amp-labs/connectors/internal/datautils"
+)
+
+// 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")
+)
+
+// 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) fetchObjectDisplayName(
+	ctx context.Context, objectName naming.SingularString,
+) (string, error) {
+	entityDefinition, err := c.metadataDiscoveryRepository.fetchEntityDefinition(ctx, objectName)
+	if err != nil {
+		return "", err
+	}
+
+	displayName, ok := entityDefinition.DisplayCollectionName.getName()
+	if !ok {
+		// There is no localized display name.
+		// Default to object name pluralised, that's the best we can do.
+		return objectName.Plural().String(), nil
+	}
+
+	return displayName, nil
+}
+
+// Returns fields metadata.
+// Internally will make an API call to Attributes endpoint.
+func (c *Connector) fetchFieldsForObject(
+	ctx context.Context, objectName naming.SingularString,
+) (map[string]common.FieldMetadata, error) {
+	attributes, err := c.metadataDiscoveryRepository.fetchAttributes(ctx, objectName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(attributes.Values) == 0 {
+		// nothing to read, we expected some attributes
+		return nil, fmt.Errorf("%w: %s", ErrObjectMissingAttributes, objectName)
+	}
+
+	var optionsErr error
+
+	attributesPicklists, err := c.metadataDiscoveryRepository.fetchAttributesPicklists(ctx, objectName)
+	if err != nil {
+		attributesPicklists = &attributesPicklistsResponse{}
+		optionsErr = errors.Join(optionsErr, err)
+	}
+
+	attributesStatuses, err := c.metadataDiscoveryRepository.fetchAttributesStatuses(ctx, objectName)
+	if err != nil {
+		attributesStatuses = &attributesStatusesResponse{}
+		optionsErr = errors.Join(optionsErr, err)
+	}
+
+	attributesStates, err := c.metadataDiscoveryRepository.fetchAttributesStates(ctx, objectName)
+	if err != nil {
+		attributesStates = &attributesStatesResponse{}
+		optionsErr = errors.Join(optionsErr, err)
+	}
+
+	return combineAttributesMetadata(attributes, attributesPicklists, attributesStatuses, attributesStates), optionsErr
+}
+
+// Attributes response will be converted to FieldMetadata.
+// However, this data is not enough. We need to list enumeration options for some fields.
+// Additional responses each contains a set of options.
+func combineAttributesMetadata(
+	attributes *attributesResponse, attributePicklists *attributesPicklistsResponse,
+	attributeStatuses *attributesStatusesResponse, attributeStates *attributesStatesResponse,
+) map[string]common.FieldMetadata {
+	// Regardless of the attribute type merge them into single registry.
+	// It is a `list of field values` each named after an `attribute`.
+	attributeOptions := make(datautils.NamedLists[common.FieldValue])
+	attributeOptions.MergeWith(attributePicklists.getOptionsPerAttribute())
+	attributeOptions.MergeWith(attributeStatuses.getOptionsPerAttribute())
+	attributeOptions.MergeWith(attributeStates.getOptionsPerAttribute())
+
+	fieldsMap := make(map[string]common.FieldMetadata)
+
+	for _, item := range attributes.Values {
+		name := item.getName()
+		modifiable := item.IsValidForCreate || item.IsValidForUpdate
+		valueType := item.getValueType()
+		values := attributeOptions[item.LogicalName]
+
+		fieldsMap[name] = common.FieldMetadata{
+			DisplayName:  item.getDisplayName(),
+			ValueType:    valueType,
+			ProviderType: item.AttributeTypeName.Value,
+			ReadOnly:     !modifiable,
+			Values:       values,
+		}
+	}
+
+	return fieldsMap
+}
+
+func (item attributeItem) getName() string {
+	// check if attribute has targets
+	if len(item.Targets) > 0 {
+		// This field is a reference to other entities.
+		// Apply underscore formating, because this is how such fields appear in the Read response.
+		return fmt.Sprintf(UnderscoreFieldFormat, item.LogicalName)
+	}
+
+	return item.LogicalName
+}
+
+// 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 (item attributeItem) getDisplayName() string {
+	labels := item.DisplayName.LocalizedLabels
+
+	if len(labels) != 0 {
+		// First occurring label should be sufficient to get to know display name.
+		firstLabel := labels[0]
+
+		displayLabel := firstLabel.Label
+
+		if len(displayLabel) != 0 {
+			return displayLabel
+		}
+	}
+
+	// try to use SchemaName which has better format than logical name
+	name := item.SchemaName
+	if len(name) != 0 {
+		return name
+	}
+
+	return item.LogicalName
+}
+
+// nolint:lll
+// Based on the attribute type infer value type.
+// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#types-of-attributes
+func (item attributeItem) getValueType() common.ValueType { // nolint:cyclop
+	switch item.AttributeTypeName.Value {
+	case "StringType", "MemoType":
+		return common.ValueTypeString
+	case "BooleanType":
+		return common.ValueTypeBoolean
+	case "BigIntType", "IntegerType":
+		return common.ValueTypeInt
+	case "DecimalType", "MoneyType":
+		return common.ValueTypeFloat
+	case "DateTimeType":
+		// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#date-and-time-data-attribute
+		switch item.Format {
+		case "DateAndTime", "UserLocal":
+			return common.ValueTypeDateTime
+		case "DateOnly":
+			return common.ValueTypeDate
+		default:
+			return common.ValueTypeOther
+		}
+	case "PicklistType", "StatusType", "StateType":
+		return common.ValueTypeSingleSelect
+	default:
+		// Examples: EntityNameType or
+		//
+		// ImageType.
+		// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#image-data-attributes
+		//
+		// CustomerType, LookupType, OwnerType.
+		// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#reference-data-attributes
+		//
+		// UniqueidentifierType.
+		// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#unique-identifier-data-attributes
+		//
+		// Virtual attributes
+		// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#virtual-attributes
+		//
+		// Logical attributes.
+		// https://learn.microsoft.com/en-us/dynamics365/customerengagement/on-premises/developer/introduction-to-entity-attributes?view=op-9-1#logical-attributes
+		return common.ValueTypeOther
+	}
+}
diff --git a/providers/dynamicscrm/read.go b/providers/dynamicscrm/read.go
index 8a4bf726..ff585274 100644
--- a/providers/dynamicscrm/read.go
+++ b/providers/dynamicscrm/read.go
@@ -2,13 +2,11 @@ package dynamicscrm
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"strings"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/urlbuilder"
-	"github.com/spyzhov/ajson"
 )
 
 // nolint:lll
@@ -69,18 +67,3 @@ 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
-	}
-
-	body, ok := rsp.Body()
-	if !ok {
-		return nil, errors.Join(ErrObjectNotFound, common.ErrEmptyJSONHTTPResponse)
-	}
-
-	return body, nil
-}
diff --git a/providers/gong/metadata/scraping.go b/providers/gong/metadata/schemas.go
similarity index 64%
rename from providers/gong/metadata/scraping.go
rename to providers/gong/metadata/schemas.go
index 8e928f4b..c22a8d57 100644
--- a/providers/gong/metadata/scraping.go
+++ b/providers/gong/metadata/schemas.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/hubspot/metadata.go b/providers/hubspot/metadata.go
index 71764a88..55df64bb 100644
--- a/providers/hubspot/metadata.go
+++ b/providers/hubspot/metadata.go
@@ -5,11 +5,12 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
-	"time"
 
+	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/common/logging"
 	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/providers/hubspot/metadata"
 )
 
 type objectMetadataResult struct {
@@ -42,7 +43,7 @@ func (c *Connector) ListObjectMetadata( // nolint:cyclop,funlen
 
 	for _, objectName := range objectNames {
 		go func(object string) {
-			objectMetadata, err := c.describeObject(ctx, object)
+			objectMetadata, err := c.getObjectMetadata(ctx, object)
 			if err != nil {
 				errChannel <- &objectMetadataError{
 					ObjectName: object,
@@ -78,8 +79,21 @@ func (c *Connector) ListObjectMetadata( // nolint:cyclop,funlen
 	return objectsMap, nil
 }
 
-// describeObject returns object metadata for the given object name.
-func (c *Connector) describeObject(ctx context.Context, objectName string) (*common.ObjectMetadata, error) {
+// getObjectMetadata returns object metadata for the given object name.
+func (c *Connector) getObjectMetadata(ctx context.Context, objectName string) (*common.ObjectMetadata, error) {
+	if crmObjectsOutsideThePropertiesAPI.Has(objectName) {
+		return c.getObjectMetadataFromObjectAPI(ctx, objectName)
+	}
+
+	return c.getObjectMetadataFromPropertyAPI(ctx, objectName)
+}
+
+// This method describes objects that are part of Objects API using properties endpoint.
+// There is a dedicated API endpoint that is used for discovery of object properties.
+// https://developers.hubspot.com/docs/guides/api/crm/properties
+func (c *Connector) getObjectMetadataFromPropertyAPI(
+	ctx context.Context, objectName string,
+) (*common.ObjectMetadata, error) {
 	relativeURL := strings.Join([]string{"properties", objectName}, "/")
 
 	u, err := c.getURL(relativeURL)
@@ -107,6 +121,42 @@ func (c *Connector) describeObject(ctx context.Context, objectName string) (*com
 	), nil
 }
 
+// Method focuses on acquiring object properties for those objects that are not part of CRM Properties API.
+// https://developers.hubspot.com/docs/guides/api/crm/objects/companies
+// There is no discovery endpoint to acquire object properties, therefore, manual read is used.
+func (c *Connector) getObjectMetadataFromObjectAPI(
+	ctx context.Context, objectName string,
+) (*common.ObjectMetadata, error) {
+	readResult, err := c.SearchCRM(ctx, SearchCRMParams{
+		ObjectName: objectName,
+		Fields:     connectors.Fields(""), // passed to satisfy validation
+		NextPage:   "",
+		PageSize:   1,
+	})
+	if err != nil {
+		// Ignore an error and fallback to static schema.
+		return metadata.Schemas.SelectOne(c.Module.ID, objectName)
+	}
+
+	if len(readResult.Data) == 0 {
+		// Read returned no rows.
+		return metadata.Schemas.SelectOne(c.Module.ID, objectName)
+	}
+
+	fields := make(map[string]common.FieldMetadata)
+	for fieldName := range readResult.Data[0].Raw {
+		fields[fieldName] = common.FieldMetadata{
+			DisplayName:  fieldName,
+			ValueType:    common.ValueTypeOther,
+			ProviderType: "", // not available
+			ReadOnly:     false,
+			Values:       nil,
+		}
+	}
+
+	return common.NewObjectMetadata(objectName, fields), nil
+}
+
 func (c *Connector) GetPostAuthInfo(
 	ctx context.Context,
 ) (*common.PostAuthInfo, error) {
@@ -153,38 +203,21 @@ type fieldDescriptionResponse struct {
 }
 
 type fieldDescription struct {
-	UpdatedAt            time.Time                 `json:"updatedAt"`
-	CreatedAt            time.Time                 `json:"createdAt"`
 	Name                 string                    `json:"name"`
 	Label                string                    `json:"label"`
 	Type                 string                    `json:"type"`
 	FieldType            string                    `json:"fieldType"`
-	Description          string                    `json:"description"`
-	GroupName            string                    `json:"groupName"`
 	Options              []fieldEnumerationOption  `json:"options"`
-	DisplayOrder         int                       `json:"displayOrder"`
-	Calculated           bool                      `json:"calculated"`
-	ExternalOptions      bool                      `json:"externalOptions"`
-	HasUniqueValue       bool                      `json:"hasUniqueValue"`
-	Hidden               bool                      `json:"hidden"`
-	HubspotDefined       bool                      `json:"hubspotDefined"`
 	ModificationMetadata fieldModificationMetadata `json:"modificationMetadata"`
-	FormField            bool                      `json:"formField"`
-	DataSensitivity      string                    `json:"dataSensitivity"`
 }
 
 type fieldEnumerationOption struct {
-	Label        string `json:"label"`
-	Value        string `json:"value"`
-	Description  string `json:"description"`
-	DisplayOrder int    `json:"displayOrder"`
-	Hidden       bool   `json:"hidden"`
+	Label string `json:"label"`
+	Value string `json:"value"`
 }
 
 type fieldModificationMetadata struct {
-	Archivable         bool `json:"archivable"`
-	ReadOnlyDefinition bool `json:"readOnlyDefinition"`
-	ReadOnlyValue      bool `json:"readOnlyValue"`
+	ReadOnlyValue bool `json:"readOnlyValue"`
 }
 
 func (r fieldDescriptionResponse) transformToFields() map[string]common.FieldMetadata {
@@ -357,31 +390,19 @@ func parsePipelineFieldValues(response *common.JSONHTTPResponse) ([]common.Field
 		return nil, fmt.Errorf("error unmarshalling pipelines into JSON: %w", err)
 	}
 
-	// Locate the default pipeline.
-	// Note: There can be multiple pipelines, but currently, enum options are returned only for the default pipeline.
+	// Aggregate stages across all pipelines.
+	// Note: Multiple pipelines can exist for each object type, with each instance referencing a specific pipeline.
 	//
 	// Problem: In HubSpot, field options are not defined at the object level but at the instance level.
 	// This means the object identifier is required to locate the pipeline and subsequently retrieve its options.
-	var defaultPipeline *pipeline
-
-	for _, p := range resp.Pipelines {
-		if p.DisplayOrder == 0 {
-			defaultPipeline = &p
-
-			break
-		}
-	}
-
-	if defaultPipeline == nil {
-		// There is no default pipeline.
-		return nil, nil
-	}
-
-	result := make([]common.FieldValue, len(defaultPipeline.Stages))
-	for i, s := range defaultPipeline.Stages {
-		result[i] = common.FieldValue{
-			Value:        s.Value,
-			DisplayValue: s.DisplayName,
+	result := make([]common.FieldValue, 0)
+
+	for _, pipelineItem := range resp.Pipelines {
+		for _, s := range pipelineItem.Stages {
+			result = append(result, common.FieldValue{
+				Value:        s.Value,
+				DisplayValue: s.DisplayName,
+			})
 		}
 	}
 
diff --git a/providers/instantly/metadata/scraping.go b/providers/hubspot/metadata/metadata.go
similarity index 64%
rename from providers/instantly/metadata/scraping.go
rename to providers/hubspot/metadata/metadata.go
index 8e928f4b..fd1bd3fb 100644
--- a/providers/instantly/metadata/scraping.go
+++ b/providers/hubspot/metadata/metadata.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV2]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/hubspot/metadata/schemas.json b/providers/hubspot/metadata/schemas.json
new file mode 100755
index 00000000..30e60454
--- /dev/null
+++ b/providers/hubspot/metadata/schemas.json
@@ -0,0 +1,65 @@
+{
+  "modules": {
+    "CRM": {
+      "id": "CRM",
+      "path": "/crm/v3",
+      "objects": {
+        "lists": {
+          "displayName": "Lists",
+          "path": "/lists/search",
+          "responseKey": "data",
+          "fields": {
+            "additionalProperties": {
+              "displayName": "additionalProperties",
+              "valueType": "other"
+            },
+            "createdAt": {
+              "displayName": "createdAt",
+              "valueType": "other"
+            },
+            "createdById": {
+              "displayName": "createdById",
+              "valueType": "other"
+            },
+            "filtersUpdatedAt": {
+              "displayName": "filtersUpdatedAt",
+              "valueType": "other"
+            },
+            "listId": {
+              "displayName": "listId",
+              "valueType": "other"
+            },
+            "listVersion": {
+              "displayName": "listVersion",
+              "valueType": "other"
+            },
+            "name": {
+              "displayName": "name",
+              "valueType": "other"
+            },
+            "objectTypeId": {
+              "displayName": "objectTypeId",
+              "valueType": "other"
+            },
+            "processingStatus": {
+              "displayName": "processingStatus",
+              "valueType": "other"
+            },
+            "processingType": {
+              "displayName": "processingType",
+              "valueType": "other"
+            },
+            "updatedAt": {
+              "displayName": "updatedAt",
+              "valueType": "other"
+            },
+            "updatedById": {
+              "displayName": "updatedById",
+              "valueType": "other"
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/providers/hubspot/objectName.go b/providers/hubspot/objectName.go
index ad369eed..865f37e9 100644
--- a/providers/hubspot/objectName.go
+++ b/providers/hubspot/objectName.go
@@ -2,12 +2,12 @@ package hubspot
 
 import "github.com/amp-labs/connectors/internal/datautils"
 
-// This is a list of objectsNames that are part of Hubspot CRM Module but exist outside ObjectAPIs.
+// This is a list of objectsNames that are part of Hubspot CRM Module but exist outside Object Properties APIs.
 //
 // These objects cannot be accessed via `~/crm/v3/objects/{objectTypeId}/...`
 //
-// On the contrary those objects that are part of ObjectAPIs can be found
+// On the contrary those objects that are part of Object Properties APIs can be found
 // in the table here https://developers.hubspot.com/docs/guides/api/crm/understanding-the-crm#object-type-ids
-var crmObjectsOutsideTheObjectAPI = datautils.NewSet( //nolint:gochecknoglobals
+var crmObjectsOutsideThePropertiesAPI = datautils.NewSet( //nolint:gochecknoglobals
 	"lists",
 )
diff --git a/providers/hubspot/read.go b/providers/hubspot/read.go
index b18d2d55..7424822c 100644
--- a/providers/hubspot/read.go
+++ b/providers/hubspot/read.go
@@ -21,7 +21,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		return nil, err
 	}
 
-	if crmObjectsOutsideTheObjectAPI.Has(config.ObjectName) {
+	if crmObjectsOutsideThePropertiesAPI.Has(config.ObjectName) {
 		// Objects outside ObjectAPI have different endpoint while both are part of CRM module.
 		// For instance Lists are fully returned only via Search endpoint.
 		return c.SearchCRM(ctx, SearchCRMParams{
diff --git a/providers/hubspot/schema.go b/providers/hubspot/schema.go
new file mode 100644
index 00000000..67d49aa4
--- /dev/null
+++ b/providers/hubspot/schema.go
@@ -0,0 +1,29 @@
+package hubspot
+
+// https://developers.hubspot.com/docs/guides/api/crm/understanding-the-crm#object-type-ids
+//
+//nolint:gochecknoglobals
+var KnownObjectTypes = map[string]string{
+	"0-2":   "companies",
+	"0-1":   "contacts",
+	"0-3":   "deals",
+	"0-5":   "tickets",
+	"0-421": "appointments",
+	"0-48":  "calls",
+	"0-18":  "communications",
+	"0-410": "courses",
+	"0-49":  "emails",
+	"0-136": "leads",
+	"0-8":   "line_items",
+	"0-420": "listings",
+	"0-54":  "marketing_events",
+	"0-47":  "meetings",
+	"0-46":  "notes",
+	"0-116": "postal_mail",
+	"0-7":   "products",
+	"0-14":  "quotes",
+	"0-162": "services",
+	"0-69":  "subscriptions",
+	"0-27":  "tasks",
+	"0-115": "users",
+}
diff --git a/providers/hubspot/search.go b/providers/hubspot/search.go
index d985f6f7..696e5434 100644
--- a/providers/hubspot/search.go
+++ b/providers/hubspot/search.go
@@ -40,6 +40,16 @@ func (c *Connector) Search(ctx context.Context, config SearchParams) (*common.Re
 	)
 }
 
+// SearchCRM is intended for objects outside HubSpot's ObjectAPI.
+// For objects within ObjectAPI, refer to the Search method.
+//
+// Case-by-case explanation:
+// * Lists
+//   - Provider API endpoint for search
+//     https://developers.hubspot.com/docs/guides/api/crm/lists/overview#search-for-a-list
+//   - Search always returns an array of items, unlike the usual "read" operation.
+//     Therefore, the "retrieve" API endpoint is not used
+//     https://developers.hubspot.com/docs/guides/api/crm/lists/overview#retrieve-lists
 func (c *Connector) SearchCRM(ctx context.Context, config SearchCRMParams) (*common.ReadResult, error) {
 	ctx = logging.With(ctx, "connector", "hubspot")
 
diff --git a/providers/hubspot/subscriptionEvent.go b/providers/hubspot/subscriptionEvent.go
index 4b4e061d..b880bb8c 100644
--- a/providers/hubspot/subscriptionEvent.go
+++ b/providers/hubspot/subscriptionEvent.go
@@ -12,8 +12,17 @@ import (
 	"time"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/gertd/go-pluralize"
 )
 
+//nolint:lll
+/*
+Older version doc: https://developers.hubspot.com/docs/guides/api/app-management/webhooks#webhook-subscriptions
+
+Newer version doc: https://developers.hubspot.com/docs/guides/apps/public-apps/create-generic-webhook-subscriptions#general
+
+*/
+
 /*
 Note:
 SubscriptionEvent is a map[string]any as opposed to a typed struct, because the structure of the event is not known .
@@ -87,6 +96,19 @@ func (evt SubscriptionEvent) RawEventName() (string, error) {
 var errSubscriptionSupportedForObject = errors.New("subscription is not supported for the object")
 
 func (evt SubscriptionEvent) ObjectName() (string, error) {
+	objType, err := evt.ObjectTypeId()
+	if err == nil {
+		// this is newer version(generic webhook) of event case where objectTypeId is present that
+		// subscriptionType does not have reference to object name e.g) object.creation
+		// https://developers.hubspot.com/docs/guides/apps/public-apps/create-generic-webhook-subscriptions#general
+		objName, ok := KnownObjectTypes[objType]
+		if ok {
+			return pluralize.NewClient().Singular(objName), nil
+		}
+
+		return "", fmt.Errorf("%w '%s'", errSubscriptionSupportedForObject, objType)
+	}
+
 	rawEvent, err := evt.RawEventName()
 	if err != nil {
 		return "", fmt.Errorf("error getting raw event name: %w", err)
@@ -137,6 +159,12 @@ func (evt SubscriptionEvent) asMap() common.StringMap {
 	return common.StringMap(evt)
 }
 
+func (evt SubscriptionEvent) ObjectTypeId() (string, error) {
+	m := evt.asMap()
+
+	return m.GetString("objectTypeId")
+}
+
 /*
 	EXAMPLES: There is no documentation that shows data structure of subscription event.
 	Below examples were found from hubspot app settings page after login at:
diff --git a/providers/hubspot/types.go b/providers/hubspot/types.go
index dcbd4382..fe038b86 100644
--- a/providers/hubspot/types.go
+++ b/providers/hubspot/types.go
@@ -42,6 +42,7 @@ type SearchCRMParams struct {
 	Fields datautils.Set[string] // optional
 	// NextPage is an opaque token that can be used to get the next page of results.
 	NextPage common.NextPageToken // optional, only set this if you want to read the next page of results
+	PageSize int
 }
 
 func (p SearchCRMParams) ValidateParams() error {
@@ -68,9 +69,14 @@ func (p SearchCRMParams) Payload() (SearchCRMPayload, error) {
 		}
 	}
 
+	pageSize := DefaultPageSizeInt
+	if p.PageSize != 0 {
+		pageSize = p.PageSize
+	}
+
 	return SearchCRMPayload{
 		Offset: offset,
-		Count:  DefaultPageSizeInt,
+		Count:  pageSize,
 	}, nil
 }
 
diff --git a/providers/intercom/metadata/scraping.go b/providers/instantly/metadata/schemas.go
similarity index 64%
rename from providers/intercom/metadata/scraping.go
rename to providers/instantly/metadata/schemas.go
index 8e928f4b..c22a8d57 100644
--- a/providers/intercom/metadata/scraping.go
+++ b/providers/instantly/metadata/schemas.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/intercom/metadata/schemas.go b/providers/intercom/metadata/schemas.go
new file mode 100644
index 00000000..c22a8d57
--- /dev/null
+++ b/providers/intercom/metadata/schemas.go
@@ -0,0 +1,22 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+var (
+	// Static file containing a list of object metadata is embedded and can be served.
+	//
+	//go:embed schemas.json
+	schemas []byte
+
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
+
+	// Schemas is cached Object schemas.
+	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
+)
diff --git a/providers/iterable/metadata/metadata.go b/providers/iterable/metadata/metadata.go
index 8e928f4b..c22a8d57 100644
--- a/providers/iterable/metadata/metadata.go
+++ b/providers/iterable/metadata/metadata.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/keap/metadata/scraping.go b/providers/keap/metadata/scraping.go
index 8e928f4b..c22a8d57 100644
--- a/providers/keap/metadata/scraping.go
+++ b/providers/keap/metadata/scraping.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/kit/metadata/scraping.go b/providers/kit/metadata/scraping.go
index 8e928f4b..c22a8d57 100644
--- a/providers/kit/metadata/scraping.go
+++ b/providers/kit/metadata/scraping.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/klaviyo/metadata/metadata.go b/providers/klaviyo/metadata/metadata.go
index 8e928f4b..c22a8d57 100644
--- a/providers/klaviyo/metadata/metadata.go
+++ b/providers/klaviyo/metadata/metadata.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/klaviyo/objectNames.go b/providers/klaviyo/objectNames.go
index 66bd87f7..fc1fc8f2 100644
--- a/providers/klaviyo/objectNames.go
+++ b/providers/klaviyo/objectNames.go
@@ -31,7 +31,7 @@ func init() {
 		for objectName, object := range module.Objects {
 		Search:
 			for _, preferredSinceField := range prioritySinceFieldsForRead {
-				for currentField := range object.FieldsMap {
+				for currentField := range object.Fields {
 					if preferredSinceField == currentField {
 						objectsNameToSinceFieldName[moduleID][objectName] = preferredSinceField
 
diff --git a/providers/marketo/metadata/file.go b/providers/marketo/metadata/file.go
deleted file mode 100644
index 0ebd600f..00000000
--- a/providers/marketo/metadata/file.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package metadata
-
-import (
-	_ "embed"
-
-	"github.com/amp-labs/connectors/tools/fileconv"
-	"github.com/amp-labs/connectors/tools/scrapper"
-)
-
-var (
-	//go:embed schemas.json
-	schemas []byte
-
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
-
-	// Schemas is cached Object schemas.
-	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
-)
diff --git a/providers/marketo/metadata/schemas.go b/providers/marketo/metadata/schemas.go
new file mode 100644
index 00000000..4cf4a87a
--- /dev/null
+++ b/providers/marketo/metadata/schemas.go
@@ -0,0 +1,20 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+var (
+	//go:embed schemas.json
+	schemas []byte
+
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
+
+	// Schemas is cached Object schemas.
+	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
+)
diff --git a/providers/marketo/url.go b/providers/marketo/url.go
index cb2794b4..11f5ee3b 100644
--- a/providers/marketo/url.go
+++ b/providers/marketo/url.go
@@ -7,7 +7,7 @@ import (
 	"github.com/amp-labs/connectors/common/urlbuilder"
 )
 
-var restAPIPrefix = "rest" //nolint:gochecknoglobals
+const restAPIPrefix = "rest" //nolint:gochecknoglobals
 
 func (c *Connector) getURL(params common.ReadParams) (*urlbuilder.URL, error) {
 	url, err := c.getAPIURL(params.ObjectName)
diff --git a/providers/pipedrive/metadata/scraping.go b/providers/pipedrive/metadata/scraping.go
index 8e928f4b..c22a8d57 100644
--- a/providers/pipedrive/metadata/scraping.go
+++ b/providers/pipedrive/metadata/scraping.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/pipeliner/metadata/schemas.go b/providers/pipeliner/metadata/schemas.go
new file mode 100644
index 00000000..c22a8d57
--- /dev/null
+++ b/providers/pipeliner/metadata/schemas.go
@@ -0,0 +1,22 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+var (
+	// Static file containing a list of object metadata is embedded and can be served.
+	//
+	//go:embed schemas.json
+	schemas []byte
+
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
+
+	// Schemas is cached Object schemas.
+	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
+)
diff --git a/providers/pipeliner/metadata/scraping.go b/providers/pipeliner/metadata/scraping.go
deleted file mode 100644
index 8e928f4b..00000000
--- a/providers/pipeliner/metadata/scraping.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package metadata
-
-import (
-	_ "embed"
-
-	"github.com/amp-labs/connectors/tools/fileconv"
-	"github.com/amp-labs/connectors/tools/scrapper"
-)
-
-var (
-	// Static file containing a list of object metadata is embedded and can be served.
-	//
-	//go:embed schemas.json
-	schemas []byte
-
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
-
-	// Schemas is cached Object schemas.
-	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
-)
diff --git a/providers/salesloft/metadata/schemas.go b/providers/salesloft/metadata/schemas.go
new file mode 100644
index 00000000..c22a8d57
--- /dev/null
+++ b/providers/salesloft/metadata/schemas.go
@@ -0,0 +1,22 @@
+package metadata
+
+import (
+	_ "embed"
+
+	"github.com/amp-labs/connectors/internal/staticschema"
+	"github.com/amp-labs/connectors/tools/fileconv"
+	"github.com/amp-labs/connectors/tools/scrapper"
+)
+
+var (
+	// Static file containing a list of object metadata is embedded and can be served.
+	//
+	//go:embed schemas.json
+	schemas []byte
+
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
+
+	// Schemas is cached Object schemas.
+	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
+)
diff --git a/providers/salesloft/metadata/scrapping.go b/providers/salesloft/metadata/scrapping.go
deleted file mode 100644
index 8e928f4b..00000000
--- a/providers/salesloft/metadata/scrapping.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package metadata
-
-import (
-	_ "embed"
-
-	"github.com/amp-labs/connectors/tools/fileconv"
-	"github.com/amp-labs/connectors/tools/scrapper"
-)
-
-var (
-	// Static file containing a list of object metadata is embedded and can be served.
-	//
-	//go:embed schemas.json
-	schemas []byte
-
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
-
-	// Schemas is cached Object schemas.
-	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
-)
diff --git a/providers/smartlead/metadata/scraping.go b/providers/smartlead/metadata/scraping.go
index 8e928f4b..c22a8d57 100644
--- a/providers/smartlead/metadata/scraping.go
+++ b/providers/smartlead/metadata/scraping.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/providers/zendesksupport/metadata/scrapping.go b/providers/zendesksupport/metadata/scrapping.go
index 8e928f4b..c22a8d57 100644
--- a/providers/zendesksupport/metadata/scrapping.go
+++ b/providers/zendesksupport/metadata/scrapping.go
@@ -3,6 +3,7 @@ package metadata
 import (
 	_ "embed"
 
+	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/tools/fileconv"
 	"github.com/amp-labs/connectors/tools/scrapper"
 )
@@ -13,7 +14,8 @@ var (
 	//go:embed schemas.json
 	schemas []byte
 
-	FileManager = scrapper.NewMetadataFileManager(schemas, fileconv.NewSiblingFileLocator()) // nolint:gochecknoglobals
+	FileManager = scrapper.NewMetadataFileManager[staticschema.FieldMetadataMapV1]( // nolint:gochecknoglobals
+		schemas, fileconv.NewSiblingFileLocator())
 
 	// Schemas is cached Object schemas.
 	Schemas = FileManager.MustLoadSchemas() // nolint:gochecknoglobals
diff --git a/scripts/openapi/constantcontact/metadata/main.go b/scripts/openapi/constantcontact/metadata/main.go
index 855bfcb2..2dde18ff 100644
--- a/scripts/openapi/constantcontact/metadata/main.go
+++ b/scripts/openapi/constantcontact/metadata/main.go
@@ -70,7 +70,7 @@ func main() {
 	)
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -82,8 +82,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName,
-				field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/customerio/journeysapp/metadata/main.go b/scripts/openapi/customerio/journeysapp/metadata/main.go
index ed5877cc..751708bd 100644
--- a/scripts/openapi/customerio/journeysapp/metadata/main.go
+++ b/scripts/openapi/customerio/journeysapp/metadata/main.go
@@ -47,7 +47,7 @@ func main() {
 	)
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -59,7 +59,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName, field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/gong/metadata/main.go b/scripts/openapi/gong/metadata/main.go
index 1e2966eb..d7a35c2d 100644
--- a/scripts/openapi/gong/metadata/main.go
+++ b/scripts/openapi/gong/metadata/main.go
@@ -69,7 +69,7 @@ func main() {
 	)
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -81,7 +81,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName, field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/intercom/metadata/main.go b/scripts/openapi/intercom/metadata/main.go
index c9f0ef7e..9edf7539 100644
--- a/scripts/openapi/intercom/metadata/main.go
+++ b/scripts/openapi/intercom/metadata/main.go
@@ -69,7 +69,7 @@ func main() {
 
 	objects := searchObjects.Combine(readObjects)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -81,7 +81,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName, field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/iterable/metadata/main.go b/scripts/openapi/iterable/metadata/main.go
index 9617387a..89854e1c 100644
--- a/scripts/openapi/iterable/metadata/main.go
+++ b/scripts/openapi/iterable/metadata/main.go
@@ -69,7 +69,7 @@ func main() {
 	)
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -81,8 +81,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName,
-				field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/keap/metadata/main.go b/scripts/openapi/keap/metadata/main.go
index bdeb2dbb..27c93125 100644
--- a/scripts/openapi/keap/metadata/main.go
+++ b/scripts/openapi/keap/metadata/main.go
@@ -16,7 +16,7 @@ import (
 )
 
 func main() {
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 	lists := datautils.IndexedLists[common.ModuleID, api3.Schema]{}
 
@@ -33,8 +33,10 @@ func main() {
 			}
 
 			for _, field := range object.Fields {
-				schemas.Add(module, object.ObjectName, object.DisplayName, field,
-					object.URLPath, object.ResponseKey, nil)
+				schemas.Add(module, object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+					staticschema.FieldMetadataMapV1{
+						field: field,
+					}, nil)
 			}
 
 			for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/klaviyo/metadata/main.go b/scripts/openapi/klaviyo/metadata/main.go
index 53fb83a8..2271471e 100644
--- a/scripts/openapi/klaviyo/metadata/main.go
+++ b/scripts/openapi/klaviyo/metadata/main.go
@@ -47,7 +47,7 @@ func main() {
 	)
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -59,8 +59,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add(klaviyo.Module2024Oct15, object.ObjectName, object.DisplayName,
-				field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add(klaviyo.Module2024Oct15, object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/marketo/metadata/metadata.go b/scripts/openapi/marketo/metadata/metadata.go
index f7e58ace..e8976b25 100644
--- a/scripts/openapi/marketo/metadata/metadata.go
+++ b/scripts/openapi/marketo/metadata/metadata.go
@@ -33,7 +33,7 @@ func main() {
 	goutils.MustBeNil(err)
 
 	// Initializes an empty ObjectMetadata variable
-	objectMetadata := make(map[string]staticschema.Object)
+	objectMetadata := make(map[string]staticschema.Object[staticschema.FieldMetadataMapV1])
 
 	// Add Lead metadata details
 	objectMetadata = generateMetadata(ldef, docL, objectMetadata)
@@ -41,8 +41,8 @@ func main() {
 	// Adds Assets Metadata details to the same variable declared above.
 	objectMetadata = generateMetadata(def, docA, objectMetadata)
 
-	goutils.MustBeNil(metadata.FileManager.SaveSchemas(&staticschema.Metadata{
-		Modules: map[common.ModuleID]staticschema.Module{
+	goutils.MustBeNil(metadata.FileManager.SaveSchemas(&staticschema.Metadata[staticschema.FieldMetadataMapV1]{
+		Modules: map[common.ModuleID]staticschema.Module[staticschema.FieldMetadataMapV1]{
 			staticschema.RootModuleID: {
 				Objects: objectMetadata,
 			},
@@ -95,8 +95,8 @@ func cleanDefinitions(def string) string {
 }
 
 func generateMetadata(objDefs map[string]string,
-	doc openapi2.T, objectMetadata map[string]staticschema.Object,
-) map[string]staticschema.Object {
+	doc openapi2.T, objectMetadata map[string]staticschema.Object[staticschema.FieldMetadataMapV1],
+) map[string]staticschema.Object[staticschema.FieldMetadataMapV1] {
 	for obj, dfn := range objDefs {
 		schem := doc.Definitions[dfn].Value.Properties
 
@@ -112,10 +112,10 @@ func generateMetadata(objDefs map[string]string,
 			fields[k] = k
 		}
 
-		om := staticschema.Object{
+		om := staticschema.Object[staticschema.FieldMetadataMapV1]{
 			DisplayName: obj,
 			URLPath:     fmt.Sprintf("/%v", obj),
-			FieldsMap:   fields,
+			Fields:      fields,
 		}
 
 		objectMetadata[obj] = om
diff --git a/scripts/openapi/pipedrive/metadata/main.go b/scripts/openapi/pipedrive/metadata/main.go
index 56d6ad9d..6ec4b13a 100644
--- a/scripts/openapi/pipedrive/metadata/main.go
+++ b/scripts/openapi/pipedrive/metadata/main.go
@@ -75,7 +75,7 @@ func main() {
 		log.Fatalln(err)
 	}
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -87,7 +87,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName, field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/pipeliner/metadata/main.go b/scripts/openapi/pipeliner/metadata/main.go
index f8ea64e9..7f635fe8 100644
--- a/scripts/openapi/pipeliner/metadata/main.go
+++ b/scripts/openapi/pipeliner/metadata/main.go
@@ -45,7 +45,7 @@ func main() {
 	)
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 
 	for _, object := range objects {
@@ -57,7 +57,10 @@ func main() {
 		}
 
 		for _, field := range object.Fields {
-			schemas.Add("", object.ObjectName, object.DisplayName, field, object.URLPath, object.ResponseKey, nil)
+			schemas.Add("", object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+				staticschema.FieldMetadataMapV1{
+					field: field,
+				}, nil)
 		}
 
 		for _, queryParam := range object.QueryParams {
diff --git a/scripts/openapi/zendesksupport/metadata/main.go b/scripts/openapi/zendesksupport/metadata/main.go
index 22939040..877eedb1 100644
--- a/scripts/openapi/zendesksupport/metadata/main.go
+++ b/scripts/openapi/zendesksupport/metadata/main.go
@@ -16,7 +16,7 @@ import (
 )
 
 func main() {
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 	registry := datautils.NamedLists[string]{}
 	lists := datautils.IndexedLists[common.ModuleID, api3.Schema]{}
 
@@ -33,8 +33,10 @@ func main() {
 			}
 
 			for _, field := range object.Fields {
-				schemas.Add(module, object.ObjectName, object.DisplayName,
-					field, object.URLPath, object.ResponseKey, nil)
+				schemas.Add(module, object.ObjectName, object.DisplayName, object.URLPath, object.ResponseKey,
+					staticschema.FieldMetadataMapV1{
+						field: field,
+					}, nil)
 			}
 
 			for _, queryParam := range object.QueryParams {
diff --git a/scripts/scrapper/salesloft/metadata/main.go b/scripts/scrapper/salesloft/metadata/main.go
index bc02b59e..41691147 100644
--- a/scripts/scrapper/salesloft/metadata/main.go
+++ b/scripts/scrapper/salesloft/metadata/main.go
@@ -69,7 +69,7 @@ func createSchemas() {
 	index, err := metadata.FileManager.LoadIndex()
 	goutils.MustBeNil(err)
 
-	schemas := staticschema.NewMetadata()
+	schemas := staticschema.NewMetadata[staticschema.FieldMetadataMapV1]()
 
 	filteredListDocs := getFilteredListDocs(index)
 	for i := range filteredListDocs { // nolint:varnamelen
@@ -89,7 +89,10 @@ func createSchemas() {
 						newDisplayName, isList := handleDisplayName(model.DisplayName)
 						if isList {
 							schemas.Add("", modelName,
-								newDisplayName, fieldName, fmt.Sprintf("/%v", modelName), "data", &model.URL)
+								newDisplayName, fmt.Sprintf("/%v", modelName), "data",
+								staticschema.FieldMetadataMapV1{
+									fieldName: fieldName,
+								}, &model.URL)
 						}
 					}
 				})
diff --git a/tools/scrapper/files.go b/tools/scrapper/files.go
index 6e93d3d6..8cc32bed 100644
--- a/tools/scrapper/files.go
+++ b/tools/scrapper/files.go
@@ -10,27 +10,29 @@ const (
 	QueryParamStatsFile = "queryParamStats.json"
 )
 
-type MetadataFileManager struct {
-	staticschema.FileManager
+type MetadataFileManager[F staticschema.FieldMetadataMap] struct {
+	staticschema.FileManager[F]
 
 	locator fileconv.FileLocator
 	flush   fileconv.Flusher
 }
 
-func NewMetadataFileManager(schemas []byte, locator fileconv.FileLocator) *MetadataFileManager {
-	return &MetadataFileManager{
-		FileManager: *staticschema.NewFileManager(schemas, locator),
+func NewMetadataFileManager[F staticschema.FieldMetadataMap](
+	schemas []byte, locator fileconv.FileLocator,
+) *MetadataFileManager[F] {
+	return &MetadataFileManager[F]{
+		FileManager: *staticschema.NewFileManager[F](schemas, locator),
 		locator:     locator,
 	}
 }
 
-func (m MetadataFileManager) SaveIndex(index *ModelURLRegistry) error {
+func (m MetadataFileManager[F]) SaveIndex(index *ModelURLRegistry) error {
 	index.Sort()
 
 	return m.flush.ToFile(m.locator.AbsPathTo(IndexFile), index)
 }
 
-func (m MetadataFileManager) LoadIndex() (*ModelURLRegistry, error) {
+func (m MetadataFileManager[F]) LoadIndex() (*ModelURLRegistry, error) {
 	var registry *ModelURLRegistry
 
 	err := LoadFile(m.locator.AbsPathTo(IndexFile), &registry)
@@ -41,6 +43,6 @@ func (m MetadataFileManager) LoadIndex() (*ModelURLRegistry, error) {
 	return registry, nil
 }
 
-func (m MetadataFileManager) SaveQueryParamStats(stats *QueryParamStats) error {
+func (m MetadataFileManager[F]) SaveQueryParamStats(stats *QueryParamStats) error {
 	return m.flush.ToFile(m.locator.AbsPathTo(QueryParamStatsFile), stats)
 }


Test patch:
diff --git a/providers/constantcontact/metadata_test.go b/providers/constantcontact/metadata_test.go
index 1e329cc0..51ccd70b 100644
--- a/providers/constantcontact/metadata_test.go
+++ b/providers/constantcontact/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe multiple objects with metadata",
diff --git a/providers/customerapp/metadata_test.go b/providers/customerapp/metadata_test.go
index f96ed252..e3da769c 100644
--- a/providers/customerapp/metadata_test.go
+++ b/providers/customerapp/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe multiple objects with metadata",
diff --git a/providers/dynamicscrm/metadata_test.go b/providers/dynamicscrm/metadata_test.go
index 161208c5..d7078e29 100644
--- a/providers/dynamicscrm/metadata_test.go
+++ b/providers/dynamicscrm/metadata_test.go
@@ -6,18 +6,22 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 	"github.com/amp-labs/connectors/test/utils/testutils"
 )
 
-func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
+func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop,maintidx
 	t.Parallel()
 
-	responseContactsSchema := testutils.DataFromFile(t, "contacts-schema.json")
+	responseContactsEntityDefinition := testutils.DataFromFile(t, "metadata/contacts/entity-definition.json")
 	// Attributes file is a shorter form of real Microsoft server response.
-	responseContactsAttributes := testutils.DataFromFile(t, "contacts-attributes.json")
+	responseContactsAttributes := testutils.DataFromFile(t, "metadata/contacts/attributes.json")
+	responseContactsOptionsPicklists := testutils.DataFromFile(t, "metadata/contacts/options-picklists.json")
+	responseContactsOptionsStates := testutils.DataFromFile(t, "metadata/contacts/options-states.json")
+	responseContactsOptionsStatuses := testutils.DataFromFile(t, "metadata/contacts/options-statuses.json")
 
 	tests := []testroutines.Metadata{
 		{
@@ -27,52 +31,88 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			ExpectedErrs: []error{common.ErrMissingObjects},
 		},
 		{
-			Name:  "Schema endpoint is not available for object",
-			Input: []string{"butterflies"},
-			Server: mockserver.Fixed{
-				Setup:  mockserver.ContentJSON(),
-				Always: mockserver.Response(http.StatusOK, responseContactsSchema),
-			}.Server(),
-			ExpectedErrs: []error{ErrObjectNotFound},
+			Name:       "Failure to return attributes for an object",
+			Input:      []string{"butterflies"},
+			Server:     mockserver.Dummy(),
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Errors: map[string]error{
+					"butterflies": mockutils.ExpectedSubsetErrors{
+						ErrFetchAttributes,
+						ErrObjectNotFound,
+					},
+				},
+			},
 		},
 		{
-			Name:  "Attributes endpoint is not available for object",
-			Input: []string{"butterflies"},
-			Server: mockserver.Conditional{
+			Name:  "Provider returns empty list of attributes",
+			Input: []string{"contacts"},
+			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
-				If:    mockcond.PathSuffix("EntityDefinitions(LogicalName='butterfly')"),
-				Then:  mockserver.Response(http.StatusOK, responseContactsSchema),
-				Else:  mockserver.Response(http.StatusOK, []byte{}),
+				Cases: []mockserver.Case{{
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')"),
+					Then: mockserver.Response(http.StatusOK, responseContactsEntityDefinition),
+				}, {
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')/Attributes"),
+					Then: mockserver.ResponseString(http.StatusOK, `{"value":[]}`), // no object attributes
+				}},
+				Default: mockserver.Response(http.StatusOK, []byte{}),
 			}.Server(),
-			ExpectedErrs: []error{ErrObjectNotFound},
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Errors: map[string]error{
+					"contacts": mockutils.ExpectedSubsetErrors{
+						ErrObjectMissingAttributes,
+					},
+				},
+			},
 		},
 		{
-			Name:  "Object doesn't have attributes",
-			Input: []string{"accounts"},
+			Name:  "Failure to fetch PicklistType, StatusType and StateType enumeration options",
+			Input: []string{"contacts"},
 			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
 				Cases: []mockserver.Case{{
-					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='account')"),
-					Then: mockserver.Response(http.StatusOK, responseContactsSchema),
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')"),
+					Then: mockserver.Response(http.StatusOK, responseContactsEntityDefinition),
 				}, {
-					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='account')/Attributes"),
-					Then: mockserver.ResponseString(http.StatusOK, `{"value":[]}`),
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')/Attributes"),
+					Then: mockserver.Response(http.StatusOK, responseContactsAttributes),
 				}},
 				Default: mockserver.Response(http.StatusOK, []byte{}),
 			}.Server(),
-			ExpectedErrs: []error{ErrObjectMissingAttributes},
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Errors: map[string]error{
+					"contacts": mockutils.ExpectedSubsetErrors{
+						ErrFetchAttributesPicklists,
+						ErrFetchAttributesStatuses,
+						ErrFetchAttributesStates,
+						common.ErrEmptyJSONHTTPResponse,
+					},
+				},
+			},
 		},
 		{
-			Name:  "Correctly list metadata for account leads and invite contact",
+			Name:  "Successfully collect and combine metadata for an object",
 			Input: []string{"contacts"},
 			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
 				Cases: []mockserver.Case{{
 					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')"),
-					Then: mockserver.Response(http.StatusOK, responseContactsSchema),
+					Then: mockserver.Response(http.StatusOK, responseContactsEntityDefinition),
 				}, {
 					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')/Attributes"),
 					Then: mockserver.Response(http.StatusOK, responseContactsAttributes),
+				}, {
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')/Attributes/Microsoft.Dynamics.CRM.PicklistAttributeMetadata"), // nolint:lll
+					Then: mockserver.Response(http.StatusOK, responseContactsOptionsPicklists),
+				}, {
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')/Attributes/Microsoft.Dynamics.CRM.StatusAttributeMetadata"), // nolint:lll
+					Then: mockserver.Response(http.StatusOK, responseContactsOptionsStatuses),
+				}, {
+					If:   mockcond.PathSuffix("EntityDefinitions(LogicalName='contact')/Attributes/Microsoft.Dynamics.CRM.StateAttributeMetadata"), // nolint:lll
+					Then: mockserver.Response(http.StatusOK, responseContactsOptionsStates),
 				}},
 				Default: mockserver.Response(http.StatusOK, []byte{}),
 			}.Server(),
@@ -80,14 +120,165 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Expected: &common.ListObjectMetadataResult{
 				Result: map[string]common.ObjectMetadata{
 					"contacts": {
-						DisplayName: "contacts",
+						DisplayName: "Contacts",
+						Fields: map[string]common.FieldMetadata{
+							"adx_identity_newpassword": {
+								DisplayName:  "New Password Input",
+								ValueType:    "string",
+								ProviderType: "StringType",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"adx_publicprofilecopy": {
+								DisplayName:  "Public Profile Copy",
+								ValueType:    "string",
+								ProviderType: "MemoType",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"merged": {
+								DisplayName:  "Merged",
+								ValueType:    "boolean",
+								ProviderType: "BooleanType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"versionnumber": {
+								DisplayName:  "Version Number",
+								ValueType:    "int",
+								ProviderType: "BigIntType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"importsequencenumber": {
+								DisplayName:  "Import Sequence Number",
+								ValueType:    "int",
+								ProviderType: "IntegerType",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"exchangerate": {
+								DisplayName:  "Exchange Rate",
+								ValueType:    "float",
+								ProviderType: "DecimalType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"annualincome": {
+								DisplayName:  "Annual Income",
+								ValueType:    "float",
+								ProviderType: "MoneyType",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"birthdate": {
+								DisplayName:  "Birthday",
+								ValueType:    "date",
+								ProviderType: "DateTimeType",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"createdon": {
+								DisplayName:  "Created On",
+								ValueType:    "datetime",
+								ProviderType: "DateTimeType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"statecode": {
+								DisplayName:  "Status",
+								ValueType:    "singleSelect",
+								ProviderType: "StateType",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "0",
+									DisplayValue: "Active",
+								}, {
+									Value:        "1",
+									DisplayValue: "Inactive",
+								}},
+							},
+							"statuscode": {
+								DisplayName:  "Status Reason",
+								ValueType:    "singleSelect",
+								ProviderType: "StatusType",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "1",
+									DisplayValue: "Active",
+								}, {
+									Value:        "2",
+									DisplayValue: "Inactive",
+								}},
+							},
+							"gendercode": {
+								DisplayName:  "Gender",
+								ValueType:    "singleSelect",
+								ProviderType: "PicklistType",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "1",
+									DisplayValue: "Male",
+								}, {
+									Value:        "2",
+									DisplayValue: "Female",
+								}},
+							},
+							"familystatuscode": {
+								DisplayName:  "Marital Status",
+								ValueType:    "singleSelect",
+								ProviderType: "PicklistType",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "1",
+									DisplayValue: "Single",
+								}, {
+									Value:        "2",
+									DisplayValue: "Married",
+								}, {
+									Value:        "3",
+									DisplayValue: "Divorced",
+								}, {
+									Value:        "4",
+									DisplayValue: "Widowed",
+								}},
+							},
+							"educationcode": {
+								DisplayName:  "Education",
+								ValueType:    "singleSelect",
+								ProviderType: "PicklistType",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "1",
+									DisplayValue: "Default Value",
+								}},
+							},
+							"leadsourcecodename": {
+								DisplayName:  "LeadSourceCodeName",
+								ValueType:    "other",
+								ProviderType: "VirtualType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"_accountid_value": {
+								DisplayName:  "Account",
+								ValueType:    "other",
+								ProviderType: "LookupType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+							"_createdby_value": {
+								DisplayName:  "Created By",
+								ValueType:    "other",
+								ProviderType: "LookupType",
+								ReadOnly:     true,
+								Values:       nil,
+							},
+						},
 						FieldsMap: map[string]string{
 							// 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
@@ -98,7 +289,6 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 				},
 				Errors: nil,
 			},
-			ExpectedErrs: nil,
 		},
 	}
 
diff --git a/providers/dynamicscrm/test/contacts-attributes.json b/providers/dynamicscrm/test/contacts-attributes.json
deleted file mode 100644
index 14398cf3..00000000
--- a/providers/dynamicscrm/test/contacts-attributes.json
+++ /dev/null
@@ -1,188 +0,0 @@
-{
-  "@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/providers/dynamicscrm/test/metadata/contacts/attributes.json b/providers/dynamicscrm/test/metadata/contacts/attributes.json
new file mode 100644
index 00000000..fa0833d3
--- /dev/null
+++ b/providers/dynamicscrm/test/metadata/contacts/attributes.json
@@ -0,0 +1,328 @@
+{
+  "@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.StringAttributeMetadata",
+      "LogicalName": "adx_identity_newpassword",
+      "SchemaName": "adx_identity_newpassword",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "StringType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "New Password Input"
+          }
+        ]
+      },
+      "Format": "Text"
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.MemoAttributeMetadata",
+      "LogicalName": "adx_publicprofilecopy",
+      "SchemaName": "adx_PublicProfileCopy",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "MemoType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Public Profile Copy"
+          }
+        ]
+      },
+      "Format": "Text"
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.BooleanAttributeMetadata",
+      "LogicalName": "merged",
+      "SchemaName": "Merged",
+      "Targets": null,
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "BooleanType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Merged"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.BigIntAttributeMetadata",
+      "LogicalName": "versionnumber",
+      "SchemaName": "VersionNumber",
+      "Targets": null,
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "BigIntType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Version Number"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.IntegerAttributeMetadata",
+      "LogicalName": "importsequencenumber",
+      "SchemaName": "ImportSequenceNumber",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "IntegerType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Import Sequence Number"
+          }
+        ]
+      },
+      "Format": "None"
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.DecimalAttributeMetadata",
+      "LogicalName": "exchangerate",
+      "SchemaName": "ExchangeRate",
+      "Targets": null,
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "DecimalType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Exchange Rate"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.MoneyAttributeMetadata",
+      "LogicalName": "annualincome",
+      "SchemaName": "AnnualIncome",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "MoneyType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Annual Income"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.DateTimeAttributeMetadata",
+      "LogicalName": "birthdate",
+      "SchemaName": "BirthDate",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "DateTimeType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Birthday"
+          }
+        ]
+      },
+      "Format": "DateOnly"
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.DateTimeAttributeMetadata",
+      "LogicalName": "createdon",
+      "SchemaName": "CreatedOn",
+      "Targets": null,
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "DateTimeType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Created On"
+          }
+        ]
+      },
+      "Format": "DateAndTime"
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.StateAttributeMetadata",
+      "LogicalName": "statecode",
+      "SchemaName": "StateCode",
+      "Targets": null,
+      "IsValidForCreate": false,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "StateType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Status"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.StatusAttributeMetadata",
+      "LogicalName": "statuscode",
+      "SchemaName": "StatusCode",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "StatusType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Status Reason"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.PicklistAttributeMetadata",
+      "LogicalName": "gendercode",
+      "SchemaName": "GenderCode",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "PicklistType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Gender"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.PicklistAttributeMetadata",
+      "LogicalName": "familystatuscode",
+      "SchemaName": "FamilyStatusCode",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "PicklistType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Marital Status"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.PicklistAttributeMetadata",
+      "LogicalName": "educationcode",
+      "SchemaName": "EducationCode",
+      "Targets": null,
+      "IsValidForCreate": true,
+      "IsValidForUpdate": true,
+      "AttributeTypeName": {
+        "Value": "PicklistType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Education"
+          }
+        ]
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "",
+      "LogicalName": "leadsourcecodename",
+      "SchemaName": "LeadSourceCodeName",
+      "Targets": null,
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "VirtualType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": []
+      },
+      "Format": ""
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.LookupAttributeMetadata",
+      "LogicalName": "accountid",
+      "SchemaName": "AccountId",
+      "Targets": [
+        "account"
+      ],
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "LookupType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Account"
+          }
+        ]
+      },
+      "Format": "None"
+    },
+    {
+      "@odata.type": "#Microsoft.Dynamics.CRM.LookupAttributeMetadata",
+      "LogicalName": "createdby",
+      "SchemaName": "CreatedBy",
+      "Targets": [
+        "systemuser"
+      ],
+      "IsValidForCreate": false,
+      "IsValidForUpdate": false,
+      "AttributeTypeName": {
+        "Value": "LookupType"
+      },
+      "DisplayName": {
+        "LocalizedLabels": [
+          {
+            "Label": "Created By"
+          }
+        ]
+      },
+      "Format": "None"
+    }
+  ]
+}
diff --git a/providers/dynamicscrm/test/contacts-schema.json b/providers/dynamicscrm/test/metadata/contacts/entity-definition.json
similarity index 100%
rename from providers/dynamicscrm/test/contacts-schema.json
rename to providers/dynamicscrm/test/metadata/contacts/entity-definition.json
diff --git a/providers/dynamicscrm/test/metadata/contacts/options-picklists.json b/providers/dynamicscrm/test/metadata/contacts/options-picklists.json
new file mode 100644
index 00000000..c4d3e892
--- /dev/null
+++ b/providers/dynamicscrm/test/metadata/contacts/options-picklists.json
@@ -0,0 +1,263 @@
+{
+  "@odata.context": "https://org6edbc606.api.crm.dynamics.com/api/data/v9.2/$metadata#EntityDefinitions('contact')/Attributes/Microsoft.Dynamics.CRM.PicklistAttributeMetadata(LogicalName,OptionSet(Options))",
+  "value": [
+    {
+      "LogicalName": "educationcode",
+      "MetadataId": "1f1a7b51-b139-453a-a1cb-e2899494e1a3",
+      "OptionSet": {
+        "MetadataId": "96b15669-cfc3-42f9-aa6a-8f0595182c30",
+        "Options": [
+          {
+            "Value": 1,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Default Value",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "af41b506-2341-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Default Value",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "af41b506-2341-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          }
+        ]
+      }
+    },
+    {
+      "LogicalName": "familystatuscode",
+      "MetadataId": "18d22cd0-f923-4d46-97e2-9decc997d285",
+      "OptionSet": {
+        "MetadataId": "2da4a488-81c9-419d-ad1c-fb0d33a1e0ef",
+        "Options": [
+          {
+            "Value": 1,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Single",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "70abc7f4-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Single",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "70abc7f4-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          },
+          {
+            "Value": 2,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Married",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "72abc7f4-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Married",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "72abc7f4-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          },
+          {
+            "Value": 3,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Divorced",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "74abc7f4-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Divorced",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "74abc7f4-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          },
+          {
+            "Value": 4,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Widowed",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "76abc7f4-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Widowed",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "76abc7f4-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          }
+        ]
+      }
+    },
+    {
+      "LogicalName": "gendercode",
+      "MetadataId": "a81e86c9-e2e1-4c1e-81ab-8913eedaab47",
+      "OptionSet": {
+        "MetadataId": "cb7f9ccb-21b2-45b5-acf5-0a62344d0860",
+        "Options": [
+          {
+            "Value": 1,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Male",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "96a9c7f4-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Male",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "96a9c7f4-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          },
+          {
+            "Value": 2,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Female",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "98a9c7f4-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Female",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "98a9c7f4-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          }
+        ]
+      }
+    }
+  ]
+}
diff --git a/providers/dynamicscrm/test/metadata/contacts/options-states.json b/providers/dynamicscrm/test/metadata/contacts/options-states.json
new file mode 100644
index 00000000..9763b62e
--- /dev/null
+++ b/providers/dynamicscrm/test/metadata/contacts/options-states.json
@@ -0,0 +1,86 @@
+{
+  "@odata.context": "https://org6edbc606.api.crm.dynamics.com/api/data/v9.2/$metadata#EntityDefinitions('contact')/Attributes/Microsoft.Dynamics.CRM.StateAttributeMetadata(LogicalName,OptionSet(Options))",
+  "value": [
+    {
+      "LogicalName": "statecode",
+      "MetadataId": "cdc3895a-7539-41e9-966b-3f9ef805aefd",
+      "OptionSet": {
+        "MetadataId": "88fa5ad0-2a4b-4281-ac9c-b4e71fb77920",
+        "Options": [
+          {
+            "@odata.type": "#Microsoft.Dynamics.CRM.StateOptionMetadata",
+            "Value": 0,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "DefaultStatus": 1,
+            "InvariantName": "Active",
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Active",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "4754c2fa-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Active",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "4754c2fa-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          },
+          {
+            "@odata.type": "#Microsoft.Dynamics.CRM.StateOptionMetadata",
+            "Value": 1,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "DefaultStatus": 2,
+            "InvariantName": "Inactive",
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Inactive",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "4954c2fa-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Inactive",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "4954c2fa-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          }
+        ]
+      }
+    }
+  ]
+}
diff --git a/providers/dynamicscrm/test/metadata/contacts/options-statuses.json b/providers/dynamicscrm/test/metadata/contacts/options-statuses.json
new file mode 100644
index 00000000..826bda77
--- /dev/null
+++ b/providers/dynamicscrm/test/metadata/contacts/options-statuses.json
@@ -0,0 +1,86 @@
+{
+  "@odata.context": "https://org6edbc606.api.crm.dynamics.com/api/data/v9.2/$metadata#EntityDefinitions('contact')/Attributes/Microsoft.Dynamics.CRM.StatusAttributeMetadata(LogicalName,OptionSet(Options))",
+  "value": [
+    {
+      "LogicalName": "statuscode",
+      "MetadataId": "a247349a-78ab-4729-8528-515fc2719b67",
+      "OptionSet": {
+        "MetadataId": "87f21441-f53d-4654-9c26-e08e78facd78",
+        "Options": [
+          {
+            "@odata.type": "#Microsoft.Dynamics.CRM.StatusOptionMetadata",
+            "Value": 1,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "State": 0,
+            "TransitionData": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Active",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "8bcee1dc-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Active",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "8bcee1dc-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          },
+          {
+            "@odata.type": "#Microsoft.Dynamics.CRM.StatusOptionMetadata",
+            "Value": 2,
+            "Color": null,
+            "IsManaged": true,
+            "ExternalValue": null,
+            "ParentValues": [],
+            "Tag": null,
+            "IsHidden": false,
+            "MetadataId": null,
+            "HasChanged": null,
+            "State": 1,
+            "TransitionData": null,
+            "Label": {
+              "LocalizedLabels": [
+                {
+                  "Label": "Inactive",
+                  "LanguageCode": 1033,
+                  "IsManaged": true,
+                  "MetadataId": "8dcee1dc-2241-db11-898a-0007e9e17ebd",
+                  "HasChanged": null
+                }
+              ],
+              "UserLocalizedLabel": {
+                "Label": "Inactive",
+                "LanguageCode": 1033,
+                "IsManaged": true,
+                "MetadataId": "8dcee1dc-2241-db11-898a-0007e9e17ebd",
+                "HasChanged": null
+              }
+            },
+            "Description": {
+              "LocalizedLabels": [],
+              "UserLocalizedLabel": null
+            }
+          }
+        ]
+      }
+    }
+  ]
+}
diff --git a/providers/gong/metadata_test.go b/providers/gong/metadata_test.go
index 33ee86f1..998ce9fd 100644
--- a/providers/gong/metadata_test.go
+++ b/providers/gong/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:       "Successfully describe one object with metadata",
diff --git a/providers/hubspot/metadata_test.go b/providers/hubspot/metadata_test.go
index 58fdbdbb..f5a3750e 100644
--- a/providers/hubspot/metadata_test.go
+++ b/providers/hubspot/metadata_test.go
@@ -19,6 +19,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop,mai
 	metadataContactsPipelines := testutils.DataFromFile(t, "metadata-contacts-external-pipelines.json")
 	metadataDealsProperties := testutils.DataFromFile(t, "metadata-deals-properties-sampled.json")
 	metadataDealsPipelines := testutils.DataFromFile(t, "metadata-deals-external-pipelines.json")
+	responseLists := testutils.DataFromFile(t, "read-lists-1-first-page.json")
 
 	tests := []testroutines.Metadata{
 		{
@@ -293,6 +294,48 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop,mai
 			},
 			ExpectedErrs: nil,
 		},
+		{
+			Name:  "Successfully describe lists, which is outside Properties API",
+			Input: []string{"lists"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.PathSuffix("/crm/v3/lists/search"),
+				Then:  mockserver.Response(http.StatusOK, responseLists),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"lists": {
+						DisplayName: "lists",
+						Fields: map[string]common.FieldMetadata{
+							"additionalProperties": {
+								DisplayName:  "additionalProperties",
+								ValueType:    "other",
+								ProviderType: "",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"name": {
+								DisplayName:  "name",
+								ValueType:    "other",
+								ProviderType: "",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+							"updatedAt": {
+								DisplayName:  "updatedAt",
+								ValueType:    "other",
+								ProviderType: "",
+								ReadOnly:     false,
+								Values:       nil,
+							},
+						},
+					},
+				},
+				Errors: nil,
+			},
+			ExpectedErrs: nil,
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/providers/hubspot/subscriptionEvent_test.go b/providers/hubspot/subscriptionEvent_test.go
index 23b447ee..afe995d9 100644
--- a/providers/hubspot/subscriptionEvent_test.go
+++ b/providers/hubspot/subscriptionEvent_test.go
@@ -34,6 +34,14 @@ func TestExtractObjectNameFromSubscriptionEvent(t *testing.T) {
 
 	_, err = emptyObjectEvent.ObjectName()
 	assert.ErrorContains(t, err, "subscription is not supported for the object ''")
+
+	withObjectTypeId := SubscriptionEvent{
+		"objectTypeId": "0-1",
+	}
+
+	objectName, err = withObjectTypeId.ObjectName()
+	assert.NilError(t, err, "error should be nil")
+	assert.Equal(t, objectName, "contact", "object name should be parsed correctly")
 }
 
 //nolint:funlen
diff --git a/providers/instantly/metadata_test.go b/providers/instantly/metadata_test.go
index 3904b461..ebc5dead 100644
--- a/providers/instantly/metadata_test.go
+++ b/providers/instantly/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:       "Successfully describe multiple objects with metadata",
diff --git a/providers/intercom/metadata_test.go b/providers/intercom/metadata_test.go
index 5167b530..b5a65517 100644
--- a/providers/intercom/metadata_test.go
+++ b/providers/intercom/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe one object with metadata",
diff --git a/providers/iterable/metadata_test.go b/providers/iterable/metadata_test.go
index c485c918..1a53b117 100644
--- a/providers/iterable/metadata_test.go
+++ b/providers/iterable/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe multiple objects with metadata",
diff --git a/providers/keap/metadata_test.go b/providers/keap/metadata_test.go
index 613e224e..f43f0cf7 100644
--- a/providers/keap/metadata_test.go
+++ b/providers/keap/metadata_test.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
@@ -31,7 +30,7 @@ func TestListObjectMetadataV1(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:  "Successfully describe multiple objects with metadata",
diff --git a/providers/klaviyo/metadata_test.go b/providers/klaviyo/metadata_test.go
index e6d365b2..11a41639 100644
--- a/providers/klaviyo/metadata_test.go
+++ b/providers/klaviyo/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadataV1(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:       "Successfully describe multiple objects with metadata",
diff --git a/providers/marketo/metadata_test.go b/providers/marketo/metadata_test.go
new file mode 100644
index 00000000..ee9400f0
--- /dev/null
+++ b/providers/marketo/metadata_test.go
@@ -0,0 +1,82 @@
+package marketo
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	channelsResponse := testutils.DataFromFile(t, "channels.json")
+
+	tests := []testroutines.Metadata{
+		{
+			Name:         "At least one object name must be provided",
+			Input:        nil,
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:  "Successfully describe supported object",
+			Input: []string{"channels"},
+			Server: mockserver.Switch{
+				Setup: mockserver.ContentJSON(),
+				Cases: []mockserver.Case{{
+					If:   mockcond.PathSuffix("v1/channels.json"),
+					Then: mockserver.Response(http.StatusOK, channelsResponse),
+				}},
+			}.Server(),
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"channels": {
+						DisplayName: "channels",
+						FieldsMap: map[string]string{
+							"applicableProgramType": "applicableProgramType",
+							"createdAt":             "createdAt",
+							"id":                    "id",
+							"name":                  "name",
+							"progressionStatuses":   "progressionStatuses",
+							"updatedAt":             "updatedAt",
+						},
+					},
+				},
+				Errors: make(map[string]error),
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ObjectMetadataConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
+
+func constructTestConnector(serverURL string) (*Connector, error) {
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+		WithWorkspace("test-workspace"),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	// for testing we want to redirect calls to our mock server
+	connector.setBaseURL(serverURL)
+
+	return connector, nil
+}
diff --git a/providers/marketo/read_test.go b/providers/marketo/read_test.go
new file mode 100644
index 00000000..828ce11e
--- /dev/null
+++ b/providers/marketo/read_test.go
@@ -0,0 +1,100 @@
+package marketo
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/datautils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestRead(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	zeroRecords := testutils.DataFromFile(t, "zero-records.json")
+	unsupportedResponse := testutils.DataFromFile(t, "not-found.json")
+	campaignsResponse := testutils.DataFromFile(t, "campaigns.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Object Name is required",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is required",
+			Input:        common.ReadParams{ObjectName: "deals"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:  "Unsupported object",
+			Input: common.ReadParams{ObjectName: "arsenal", Fields: datautils.NewStringSet("testField")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusBadRequest, unsupportedResponse),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrCaller,
+				errors.New(string(unsupportedResponse)), //nolint:err113
+			},
+		},
+		{
+			Name:  "Zero records response",
+			Input: common.ReadParams{ObjectName: "smartcampaigns", Fields: connectors.Fields("description", "id", "name")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, zeroRecords),
+			}.Server(),
+			Expected:     &common.ReadResult{Rows: 0, Data: []common.ReadResultRow{}, Done: true},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Successfully Read Campaigns",
+			Input: common.ReadParams{
+				ObjectName: "campaign",
+				Fields:     connectors.Fields("createdAt", "id", "name"),
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, campaignsResponse),
+			}.Server(),
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"createdat": "2024-08-23T12:09:55Z",
+						"id":        float64(1023),
+						"name":      "Meme",
+					},
+					Raw: map[string]any{
+						"active":        false,
+						"createdAt":     "2024-08-23T12:09:55Z",
+						"id":            float64(1023),
+						"name":          "Meme",
+						"type":          "batch",
+						"updatedAt":     "2024-08-23T12:09:55Z",
+						"workspaceName": "Default",
+					},
+				}},
+				Done: true,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/marketo/test/campaigns.json b/providers/marketo/test/campaigns.json
new file mode 100644
index 00000000..c4c3aa20
--- /dev/null
+++ b/providers/marketo/test/campaigns.json
@@ -0,0 +1,15 @@
+{
+  "requestId": "76cc#1949367e298",
+  "result": [
+    {
+      "id": 1023,
+      "name": "Meme",
+      "type": "batch",
+      "workspaceName": "Default",
+      "createdAt": "2024-08-23T12:09:55Z",
+      "updatedAt": "2024-08-23T12:09:55Z",
+      "active": false
+    }
+  ],
+  "success": true
+}
diff --git a/providers/marketo/test/channels.json b/providers/marketo/test/channels.json
new file mode 100644
index 00000000..c60446da
--- /dev/null
+++ b/providers/marketo/test/channels.json
@@ -0,0 +1,38 @@
+{
+  "success": true,
+  "errors": [],
+  "requestId": "1167b#194927caa59",
+  "warnings": [],
+  "result": [
+    {
+      "id": 3,
+      "name": "Online Advertising",
+      "applicableProgramType": "program",
+      "progressionStatuses": [
+        {
+          "name": "Not in Program",
+          "step": 0,
+          "type": "Not in Program",
+          "hidden": false,
+          "success": false
+        },
+        {
+          "name": "Filled-out Form",
+          "step": 10,
+          "type": "Filled-out Form",
+          "hidden": false,
+          "success": false
+        },
+        {
+          "name": "Influenced",
+          "step": 20,
+          "type": "Influenced",
+          "hidden": false,
+          "success": true
+        }
+      ],
+      "createdAt": "2024-03-10T09:24:11Z+0000",
+      "updatedAt": "2024-03-10T09:24:11Z+0000"
+    }
+  ]
+}
diff --git a/providers/marketo/test/not-found.json b/providers/marketo/test/not-found.json
new file mode 100644
index 00000000..257ca217
--- /dev/null
+++ b/providers/marketo/test/not-found.json
@@ -0,0 +1,6 @@
+{
+  "requestId": "a883#19492823069",
+  "success": false,
+  "warnings": [],
+  "errors": [{ "code": "610", "message": "Requested resource not found" }]
+}
diff --git a/providers/marketo/test/write-leads.json b/providers/marketo/test/write-leads.json
new file mode 100644
index 00000000..192da7ee
--- /dev/null
+++ b/providers/marketo/test/write-leads.json
@@ -0,0 +1,5 @@
+{
+  "requestId": "b267#194942e22e1",
+  "result": [{ "id": 550, "status": "created" }],
+  "success": true
+}
diff --git a/providers/marketo/test/zero-records.json b/providers/marketo/test/zero-records.json
new file mode 100644
index 00000000..bf513b81
--- /dev/null
+++ b/providers/marketo/test/zero-records.json
@@ -0,0 +1,6 @@
+{
+  "success": true,
+  "errors": [],
+  "requestId": "a024#1949367df25",
+  "warnings": ["No assets found for the given search criteria."]
+}
diff --git a/providers/marketo/write_test.go b/providers/marketo/write_test.go
new file mode 100644
index 00000000..017c6389
--- /dev/null
+++ b/providers/marketo/write_test.go
@@ -0,0 +1,91 @@
+package marketo
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,gocognit,cyclop
+	t.Parallel()
+
+	unsupportedResponse := testutils.DataFromFile(t, "not-found.json")
+	leadCreationResponse := testutils.DataFromFile(t, "write-leads.json")
+
+	tests := []testroutines.Write{
+		{
+			Name:         "Object Name is required",
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "RecordData is required",
+			Input:        common.WriteParams{ObjectName: "leads"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingRecordData},
+		},
+
+		{
+			Name:  "Unsupported object",
+			Input: common.WriteParams{ObjectName: "lalala", RecordData: "dummy"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.MethodPOST(),
+				Then:  mockserver.Response(http.StatusNotFound, unsupportedResponse),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrRetryable,
+				errors.New(string(unsupportedResponse)), // nolint:goerr113
+			},
+		},
+		{
+			Name: "Successfully creation of a lead",
+			Input: common.WriteParams{ObjectName: "leads", RecordData: map[string]any{
+				"input": []map[string]any{
+					{
+						"email":     "joseph@gmail.com",
+						"firstName": "Example Lead",
+					},
+				},
+				"action":      "createOnly",
+				"lookupField": "email",
+			}},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If: mockcond.And{
+					mockcond.MethodPOST(),
+					mockcond.PathSuffix("/v1/leads.json"),
+				},
+				Then: mockserver.Response(http.StatusOK, leadCreationResponse),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetWrite,
+			Expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "550",
+				Data: map[string]any{
+					"id":     float64(550),
+					"status": "created",
+				},
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.WriteConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
diff --git a/providers/pipeliner/metadata_test.go b/providers/pipeliner/metadata_test.go
index b96edf34..576e3c2f 100644
--- a/providers/pipeliner/metadata_test.go
+++ b/providers/pipeliner/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe one object with metadata",
diff --git a/providers/salesloft/metadata_test.go b/providers/salesloft/metadata_test.go
index c03eb43c..fe5af1d4 100644
--- a/providers/salesloft/metadata_test.go
+++ b/providers/salesloft/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe one object with metadata",
diff --git a/providers/smartlead/metadata_test.go b/providers/smartlead/metadata_test.go
index e76e2157..2052316b 100644
--- a/providers/smartlead/metadata_test.go
+++ b/providers/smartlead/metadata_test.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -24,7 +23,7 @@ func TestListObjectMetadata(t *testing.T) { // nolint:funlen,gocognit,cyclop
 			Name:         "Unknown object requested",
 			Input:        []string{"butterflies"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:       "Successfully describe multiple objects with metadata",
diff --git a/providers/zendesksupport/metadata_test.go b/providers/zendesksupport/metadata_test.go
index 6cadfac4..68098318 100644
--- a/providers/zendesksupport/metadata_test.go
+++ b/providers/zendesksupport/metadata_test.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
-	"github.com/amp-labs/connectors/internal/staticschema"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
 )
@@ -26,7 +25,7 @@ func TestListObjectMetadataZendeskSupportModule(t *testing.T) { // nolint:funlen
 			Name:         "Object coming from different module is unknown",
 			Input:        []string{"articles"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe one object with metadata",
@@ -114,7 +113,7 @@ func TestListObjectMetadataHelpCenterModule(t *testing.T) { // nolint:funlen,goc
 			Name:         "Object coming from different module is unknown",
 			Input:        []string{"brands"},
 			Server:       mockserver.Dummy(),
-			ExpectedErrs: []error{staticschema.ErrObjectNotFound},
+			ExpectedErrs: []error{common.ErrObjectNotSupported},
 		},
 		{
 			Name:   "Successfully describe one object with metadata",
diff --git a/test/dynamicscrm/metadata/main.go b/test/dynamicscrm/metadata/main.go
index b59f0345..4f8599b0 100644
--- a/test/dynamicscrm/metadata/main.go
+++ b/test/dynamicscrm/metadata/main.go
@@ -3,6 +3,7 @@ package main
 import (
 	"context"
 	"fmt"
+	"os"
 	"os/signal"
 	"syscall"
 
@@ -57,4 +58,6 @@ func main() {
 	}
 
 	fmt.Println("==> success fields requested from ListObjectMetadata are all present in Read.")
+
+	utils.DumpJSON(metadata, os.Stdout)
 }
diff --git a/test/dynamicscrm/read/main.go b/test/dynamicscrm/read/main.go
index 9fbd1671..1cb7bbdb 100644
--- a/test/dynamicscrm/read/main.go
+++ b/test/dynamicscrm/read/main.go
@@ -27,7 +27,7 @@ func main() {
 
 	res, err := conn.Read(ctx, common.ReadParams{
 		ObjectName: objectName,
-		Fields:     connectors.Fields("fullname", "emailaddress1", "fax"),
+		Fields:     connectors.Fields("fullname", "emailaddress1", "fax", "creditlimit_base"),
 	})
 	if err != nil {
 		utils.Fail("error reading from microsoft CRM", "error", err)
diff --git a/test/hubspot/metadata/lists/main.go b/test/hubspot/metadata/lists/main.go
new file mode 100644
index 00000000..38a18d7d
--- /dev/null
+++ b/test/hubspot/metadata/lists/main.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+	"context"
+	"os"
+	"os/signal"
+	"syscall"
+
+	connTest "github.com/amp-labs/connectors/test/hubspot"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+var objectName = "lists"
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetHubspotConnector(ctx)
+
+	metadata, err := conn.ListObjectMetadata(ctx, []string{
+		objectName,
+	})
+	if err != nil {
+		utils.Fail("error listing metadata for Hubspot", "error", err)
+	}
+
+	utils.DumpJSON(metadata, os.Stdout)
+}
diff --git a/test/utils/mockutils/metadataResult.go b/test/utils/mockutils/metadataResult.go
index 65f9969f..ae09a3af 100644
--- a/test/utils/mockutils/metadataResult.go
+++ b/test/utils/mockutils/metadataResult.go
@@ -15,10 +15,6 @@ type metadataResultComparator struct{}
 
 // SubsetFields checks that expected ListObjectMetadataResult fields are a subset of actual metadata result.
 func (metadataResultComparator) SubsetFields(actual, expected *common.ListObjectMetadataResult) bool {
-	if len(expected.Result) == 0 {
-		invalidTest("please specify expected Fields response")
-	}
-
 	for objectName, expectedMetadata := range expected.Result {
 		actualMetadata, ok := actual.Result[objectName]
 		if !ok {