Repository: amp-labs/connectors
Pull request #1321 (2025-01-31 23:21:34)

feat(common): Make ParseResult use generic Record type
# Description 

Currently, marshaling records that require extra processing—such as flattening—is not straightforward. Working with records of type `map[string]any` can be cumbersome in such cases, whereas using `*ajson.Node` simplifies the process.

# Solution
`common.ParseResult` relies on a generic record type:
* `map[string]any` – Convenient when raw and field values are similar.
* `*ajson.Node` – Requires less code for JSON manipulation and should be used when extracting specific fields.

# Utilities

Additionaly added a `common.RecordFlattener` method, which moves fields from nested object to the root.
Outreach and Klaviyo connectors produce response with similiar structure and would benefit from this method.
The algorithm is the same.

Cobalt0s: * **#1320** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1320?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1321** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1321?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1321?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
RajatPawar: Let's hold off on this until we get around to refactoring some of our HTTP clients and have a good understanding of where parsing lives there - maybe it can be a part of that. 
Cobalt0s: > Let's hold off on this until we get around to refactoring some of our HTTP clients and have a good understanding of where parsing lives there - maybe it can be a part of that. Also, type constraints come with certain limitations. Until then feel free to add functions to common to flatten records, etc

@RajatPawar `common.ParseResult` had its limitation that `marshalFunc` only operates on `map[string]any`. For those connectors where raw differed from fields it made `marshalFunc` unnecessary complex, where it either had to operate on `map[string]any` by undoing work done by `recordsFunc` or create higher order function.

Instead I am suggesting to match output of `recordsFunc` with input of `marshalFunc`. By having this the connector implementation becomes a one liner: `common.MakeMarshaledDataFunc(common.RecordFlattener("attributes"))`. 

Could we have `ParseResult` live in parallel :pray: to the refactored version, in whenever place it will be or level?

The generic could be changed to type `any` if you dont like type constraints. The implementation goal is to preserve the matching chain of types from `recordsFunc` to `marshalFunc`. Not having this kind of seperation produced a bug where raw and fields were processed in `recordsFunc` affecting both.

Patch:
diff --git a/common/parse.go b/common/parse.go
index 6b46248f..455dadf2 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -11,21 +11,53 @@ import (
 )
 
 type (
+	// NextPageFunc extracts the next page token/URL from the response body.
 	NextPageFunc func(*ajson.Node) (string, error)
-	RecordsFunc  func(*ajson.Node) ([]map[string]any, error)
+
+	// RecordsFunc extracts a list of records as map[string]any from the response body.
+	RecordsFunc func(*ajson.Node) ([]map[string]any, error)
+	// NodeRecordsFunc extracts a list of records as ajson.Node from the response body.
+	NodeRecordsFunc func(*ajson.Node) ([]*ajson.Node, error)
+
+	// RecordTransformer is a function that processes a JSON node and transforms it
+	// into a map representation, potentially applying structural modifications.
+	//
+	// Common use cases include:
+	// - Flattening nested objects (see FlattenNestedFields)
+	// - Filtering out unwanted fields
+	// - Converting field formats
+	// - Renaming fields
+	// - Adding computed fields
+	//
+	// Example usage:
+	// - FlattenNestedFields("attributes")
+	// - Replacing GUIDs with human-readable fields
+	// - Enhancing fields with custom data from the API response.
+	// - Adding a relationships property to the root level from a nested object.
+	RecordTransformer func(node *ajson.Node) (map[string]any, error)
+	// MarshalFunc converts a list of map[string]any records into ReadResultRow format.
+	MarshalFunc func(records []map[string]any, fields []string) ([]ReadResultRow, error)
+	// MarshalFromNodeFunc converts a list of ajson.Node records into ReadResultRow format.
+	MarshalFromNodeFunc func(records []*ajson.Node, fields []string) ([]ReadResultRow, error)
 )
 
+// ProviderReadResponseType defines the types of records that ParseResult can process.
+// It determines which callback function is used for parsing.
+type ProviderReadResponseType interface {
+	map[string]any | *ajson.Node
+}
+
 // ParseResult parses the response from a provider into a ReadResult. A 2xx return type is assumed.
 // The sizeFunc returns the total number of records in the response.
-// The recordsFunc returns the records in the response.
-// The nextPageFunc returns the URL for the next page of results.
+// The extractRecords returns the records in the response.
+// The extractNextPage returns the URL for the next page of results.
 // The marshalFunc is used to structure the data into an array of ReadResultRows.
 // The fields are used to populate ReadResultRow.Fields.
-func ParseResult(
+func ParseResult[R ProviderReadResponseType](
 	resp *JSONHTTPResponse,
-	recordsFunc func(*ajson.Node) ([]map[string]any, error),
-	nextPageFunc func(*ajson.Node) (string, error),
-	marshalFunc func([]map[string]any, []string) ([]ReadResultRow, error),
+	extractRecords func(*ajson.Node) ([]R, error),
+	extractNextPage func(*ajson.Node) (string, error),
+	marshalFunc func([]R, []string) ([]ReadResultRow, error),
 	fields datautils.Set[string],
 ) (*ReadResult, error) {
 	body, ok := resp.Body()
@@ -33,12 +65,12 @@ func ParseResult(
 		return nil, ErrEmptyJSONHTTPResponse
 	}
 
-	records, err := recordsFunc(body)
+	records, err := extractRecords(body)
 	if err != nil {
 		return nil, err
 	}
 
-	nextPage, err := nextPageFunc(body)
+	nextPage, err := extractNextPage(body)
 	if err != nil {
 		return nil, err
 	}
@@ -75,11 +107,11 @@ func ParseResult(
 
 // ExtractLowercaseFieldsFromRaw returns a map of fields from a record.
 // The fields are all returned in lowercase.
-func ExtractLowercaseFieldsFromRaw(fields []string, record map[string]interface{}) map[string]interface{} {
-	out := make(map[string]interface{}, len(fields))
+func ExtractLowercaseFieldsFromRaw(fields []string, record map[string]any) map[string]any {
+	out := make(map[string]any, len(fields))
 
 	// Modify all record keys to lowercase
-	lowercaseRecord := make(map[string]interface{}, len(record))
+	lowercaseRecord := make(map[string]any, len(record))
 	for key, value := range record {
 		lowercaseRecord[strings.ToLower(key)] = value
 	}
@@ -143,15 +175,43 @@ func GetMarshalledDataWithId(records []map[string]any, fields []string) ([]ReadR
 	return data, nil
 }
 
-func GetRecordsUnderJSONPath(jsonPath string, nestedPath ...string) RecordsFunc {
-	return getRecords(false, jsonPath, nestedPath...)
+// MakeMarshaledDataFunc produces ReadResultRow where raw record differs from the fields.
+// This usually includes a set of actions to preprocess, usually to flatten the raw record and then extract
+// fields requested by the user.
+func MakeMarshaledDataFunc(nodeRecordFunc RecordTransformer) MarshalFromNodeFunc {
+	return func(records []*ajson.Node, fields []string) ([]ReadResultRow, error) {
+		data := make([]ReadResultRow, len(records))
+
+		for index, nodeRecord := range records {
+			raw, err := jsonquery.Convertor.ObjectToMap(nodeRecord)
+			if err != nil {
+				return nil, err
+			}
+
+			record, err := nodeRecordFunc(nodeRecord)
+			if err != nil {
+				return nil, err
+			}
+
+			data[index] = ReadResultRow{
+				Fields: ExtractLowercaseFieldsFromRaw(fields, record),
+				Raw:    raw,
+			}
+		}
+
+		return data, nil
+	}
 }
 
-func GetOptionalRecordsUnderJSONPath(jsonPath string, nestedPath ...string) RecordsFunc {
-	return getRecords(true, jsonPath, nestedPath...)
+func ExtractRecordsFromPath(jsonPath string, nestedPath ...string) RecordsFunc {
+	return extractRecords(false, jsonPath, nestedPath...)
 }
 
-func getRecords(optional bool, jsonPath string, nestedPath ...string) RecordsFunc {
+func ExtractOptionalRecordsFromPath(jsonPath string, nestedPath ...string) RecordsFunc {
+	return extractRecords(true, jsonPath, nestedPath...)
+}
+
+func extractRecords(optional bool, jsonPath string, nestedPath ...string) RecordsFunc {
 	return func(node *ajson.Node) ([]map[string]any, error) {
 		var (
 			arr []*ajson.Node
@@ -171,3 +231,54 @@ func getRecords(optional bool, jsonPath string, nestedPath ...string) RecordsFun
 		return jsonquery.Convertor.ArrayToMap(arr)
 	}
 }
+
+// FlattenNestedFields returns a procedure which copies fields of a nested object to the top level.
+//
+// Ex: Every object has special field "attributes" which holds all the object specific fields.
+// Therefore, nested "attributes" will be removed and fields will be moved to the top level of the object.
+//
+// Example accounts(shortened response):
+//
+//	 "data": [
+//	    {
+//	        "type": "",
+//	        "id": "",
+//	        "attributes": {
+//	            "test_account": false,
+//	            "contact_information": {},
+//	            "locale": ""
+//	        },
+//	        "links": {}
+//	    }
+//	],
+//
+// The resulting fields for the above will be: [ type, id, test_account, contact_information, locale, links ].
+func FlattenNestedFields(nestedKey string) RecordTransformer {
+	return func(node *ajson.Node) (map[string]any, error) {
+		attributes, err := jsonquery.New(node).ObjectOptional(nestedKey)
+		if err != nil {
+			return nil, err
+		}
+
+		root, err := jsonquery.Convertor.ObjectToMap(node)
+		if err != nil {
+			return nil, err
+		}
+
+		nested, err := jsonquery.Convertor.ObjectToMap(attributes)
+		if err != nil {
+			return nil, err
+		}
+
+		// Nested object will be removed.
+		delete(root, nestedKey)
+
+		// Fields from attributes are moved to the top level.
+		for key, value := range nested {
+			root[key] = value
+		}
+
+		// Root level has adopted fields from nested object.
+		return root, nil
+	}
+}
diff --git a/providers/aha/handlers.go b/providers/aha/handlers.go
index 470cef4e..99e801b9 100644
--- a/providers/aha/handlers.go
+++ b/providers/aha/handlers.go
@@ -64,7 +64,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseKey),
+		common.ExtractRecordsFromPath(responseKey),
 		makeNextRecordsURL(baseURL, params),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/asana/read.go b/providers/asana/read.go
index 85c90955..79db25de 100644
--- a/providers/asana/read.go
+++ b/providers/asana/read.go
@@ -30,7 +30,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetOptionalRecordsUnderJSONPath("data"),
+		common.ExtractOptionalRecordsFromPath("data"),
 		makeNextRecordsURL(),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/ashby/handlers.go b/providers/ashby/handlers.go
index e84529b3..4acc6f06 100644
--- a/providers/ashby/handlers.go
+++ b/providers/ashby/handlers.go
@@ -66,7 +66,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseKey),
+		common.ExtractRecordsFromPath(responseKey),
 		makeNextRecordsURL,
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/atlassian/parse.go b/providers/atlassian/parse.go
index 663a08ac..e1a207b2 100644
--- a/providers/atlassian/parse.go
+++ b/providers/atlassian/parse.go
@@ -30,36 +30,30 @@ Visual example of what will happen to each property:
 		}
 	}
 */
-func getRecords(node *ajson.Node) ([]map[string]any, error) {
-	arr, err := jsonquery.New(node).ArrayRequired("issues")
+func flattenRecord(item *ajson.Node) (map[string]any, error) {
+	fieldsObject, err := jsonquery.New(item).ObjectRequired("fields")
 	if err != nil {
-		return nil, err
+		return nil, errors.Join(common.ErrParseError, err)
 	}
 
-	list := make([]map[string]any, len(arr))
-
-	for index, item := range arr {
-		fieldsObject, err := jsonquery.New(item).ObjectRequired("fields")
-		if err != nil {
-			return nil, errors.Join(common.ErrParseError, err)
-		}
+	fields, err := jsonquery.Convertor.ObjectToMap(fieldsObject)
+	if err != nil {
+		return nil, errors.Join(common.ErrParseError, err)
+	}
 
-		fields, err := jsonquery.Convertor.ObjectToMap(fieldsObject)
-		if err != nil {
-			return nil, errors.Join(common.ErrParseError, err)
-		}
+	id, err := jsonquery.New(item).StringRequired("id")
+	if err != nil {
+		return nil, errors.Join(common.ErrParseError, err)
+	}
 
-		id, err := jsonquery.New(item).StringRequired("id")
-		if err != nil {
-			return nil, errors.Join(common.ErrParseError, err)
-		}
+	// Enhance response with id property.
+	fields["id"] = id
 
-		// Enhance response with id property.
-		fields["id"] = id
-		list[index] = fields
-	}
+	return fields, nil
+}
 
-	return list, nil
+func getRecords(node *ajson.Node) ([]*ajson.Node, error) {
+	return jsonquery.New(node).ArrayRequired("issues")
 }
 
 // Next starting page index is calculated base on current index and array size.
diff --git a/providers/atlassian/read.go b/providers/atlassian/read.go
index 491007a2..eccc1032 100644
--- a/providers/atlassian/read.go
+++ b/providers/atlassian/read.go
@@ -33,7 +33,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		rsp,
 		getRecords,
 		getNextRecords,
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(flattenRecord),
 		config.Fields,
 	)
 }
diff --git a/providers/attio/read.go b/providers/attio/read.go
index a439e9b5..0bdb3690 100644
--- a/providers/attio/read.go
+++ b/providers/attio/read.go
@@ -34,7 +34,7 @@ func (c *Connector) readAPI(ctx context.Context, config common.ReadParams) (*com
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		makeNextRecordsURL(url, config.ObjectName),
 		common.GetMarshaledData,
 		config.Fields,
@@ -65,7 +65,7 @@ func (c *Connector) readStandardOrCustomObject(
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		makeNextRecordStandardObj(offset),
 		DataMarshall(rsp),
 		config.Fields,
diff --git a/providers/brevo/handlers.go b/providers/brevo/handlers.go
index 44d65dbb..f86beac4 100644
--- a/providers/brevo/handlers.go
+++ b/providers/brevo/handlers.go
@@ -62,7 +62,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		nextRecordsURL(requestURL),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/chilipiper/read.go b/providers/chilipiper/read.go
index a9f8b1cb..7cb64ff3 100644
--- a/providers/chilipiper/read.go
+++ b/providers/chilipiper/read.go
@@ -28,7 +28,7 @@ func (conn *Connector) Read(ctx context.Context, config common.ReadParams) (*com
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath("results"),
+		common.ExtractRecordsFromPath("results"),
 		nextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/clickup/handlers.go b/providers/clickup/handlers.go
index da7a85f9..75a546e0 100644
--- a/providers/clickup/handlers.go
+++ b/providers/clickup/handlers.go
@@ -43,7 +43,7 @@ func (c *Connector) parseReadResponse(
 
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		nextRecordsURL(requestURL),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/closecrm/read.go b/providers/closecrm/read.go
index 3d199631..e0330c88 100644
--- a/providers/closecrm/read.go
+++ b/providers/closecrm/read.go
@@ -45,7 +45,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		nextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/closecrm/search.go b/providers/closecrm/search.go
index 247afc27..6874bbaa 100644
--- a/providers/closecrm/search.go
+++ b/providers/closecrm/search.go
@@ -46,7 +46,7 @@ func (c *Connector) Search(ctx context.Context, config SearchParams) (*common.Re
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		getNextRecordCursor,
 		common.GetMarshaledData,
 		datautils.NewStringSet(config.Fields...),
diff --git a/providers/constantcontact/read.go b/providers/constantcontact/read.go
index ee3f14d3..722c4d13 100644
--- a/providers/constantcontact/read.go
+++ b/providers/constantcontact/read.go
@@ -32,7 +32,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
 		makeNextRecordsURL(c.BaseURL),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/customerapp/read.go b/providers/customerapp/read.go
index fa298aec..c5c9e448 100644
--- a/providers/customerapp/read.go
+++ b/providers/customerapp/read.go
@@ -30,7 +30,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/freshdesk/read.go b/providers/freshdesk/read.go
index 5e4bd140..d9c1628a 100644
--- a/providers/freshdesk/read.go
+++ b/providers/freshdesk/read.go
@@ -26,7 +26,7 @@ func (conn *Connector) Read(ctx context.Context, config common.ReadParams) (*com
 
 	return common.ParseResult(
 		resp,
-		common.GetRecordsUnderJSONPath(""), // we're reading the current node.
+		common.ExtractRecordsFromPath(""), // we're reading the current node.
 		nextRecordsURL(linkHeader),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/front/handlers.go b/providers/front/handlers.go
index ae2cfc3e..33145c7f 100644
--- a/providers/front/handlers.go
+++ b/providers/front/handlers.go
@@ -42,7 +42,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(dataResponseKey),
+		common.ExtractRecordsFromPath(dataResponseKey),
 		nextRecordsURL(),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/gong/read.go b/providers/gong/read.go
index 5c636132..397896b1 100644
--- a/providers/gong/read.go
+++ b/providers/gong/read.go
@@ -48,7 +48,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		getNextRecordsURL,
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/gorgias/parse.go b/providers/gorgias/parse.go
index 053b1a17..a6aa1692 100644
--- a/providers/gorgias/parse.go
+++ b/providers/gorgias/parse.go
@@ -17,7 +17,7 @@ func records(objectName string) common.RecordsFunc {
 
 			return []map[string]any{data}, nil
 		default:
-			return common.GetRecordsUnderJSONPath(dataField)(node)
+			return common.ExtractRecordsFromPath(dataField)(node)
 		}
 	}
 }
diff --git a/providers/groove/handlers.go b/providers/groove/handlers.go
index eb61f2dd..a3163d6c 100644
--- a/providers/groove/handlers.go
+++ b/providers/groove/handlers.go
@@ -116,7 +116,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseField(params.ObjectName)),
+		common.ExtractRecordsFromPath(responseField(params.ObjectName)),
 		nextRecordsURL(),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/hubspot/search.go b/providers/hubspot/search.go
index 6edbb220..81295f6a 100644
--- a/providers/hubspot/search.go
+++ b/providers/hubspot/search.go
@@ -82,7 +82,7 @@ func (c *Connector) searchCRM(
 
 	return common.ParseResult(
 		rsp,
-		common.GetOptionalRecordsUnderJSONPath(config.ObjectName),
+		common.ExtractOptionalRecordsFromPath(config.ObjectName),
 		getNextRecordsURLCRM,
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/instantly/read.go b/providers/instantly/read.go
index 2d8cb23a..b99a4846 100644
--- a/providers/instantly/read.go
+++ b/providers/instantly/read.go
@@ -34,7 +34,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(nodePath),
+		common.ExtractRecordsFromPath(nodePath),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/iterable/parse.go b/providers/iterable/parse.go
index 3cda77ac..fa3e3fb6 100644
--- a/providers/iterable/parse.go
+++ b/providers/iterable/parse.go
@@ -18,7 +18,7 @@ func makeGetRecords(moduleID common.ModuleID, objectName string) common.RecordsF
 			nestedPath = []string{"params"}
 		}
 
-		return common.GetOptionalRecordsUnderJSONPath(responseFieldName, nestedPath...)(node)
+		return common.ExtractOptionalRecordsFromPath(responseFieldName, nestedPath...)(node)
 	}
 }
 
diff --git a/providers/keap/parse.go b/providers/keap/parse.go
index a6859a1d..eb2757dd 100644
--- a/providers/keap/parse.go
+++ b/providers/keap/parse.go
@@ -1,13 +1,20 @@
 package keap
 
 import (
-	"context"
-
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/amp-labs/connectors/providers/keap/metadata"
 	"github.com/spyzhov/ajson"
 )
 
+func makeGetRecords(moduleID common.ModuleID, objectName string) common.NodeRecordsFunc {
+	return func(node *ajson.Node) ([]*ajson.Node, error) {
+		responseFieldName := metadata.Schemas.LookupArrayFieldName(moduleID, objectName)
+
+		return jsonquery.New(node).ArrayOptional(responseFieldName)
+	}
+}
+
 func makeNextRecordsURL(moduleID common.ModuleID) common.NextPageFunc {
 	return func(node *ajson.Node) (string, error) {
 		if moduleID == ModuleV1 {
@@ -21,25 +28,14 @@ func makeNextRecordsURL(moduleID common.ModuleID) common.NextPageFunc {
 // Before parsing the records, if any custom fields are present (without a human-readable name),
 // this will call the correct API to extend & replace the custom field with human-readable information.
 // Object will then be enhanced using model.
-func (c *Connector) parseReadRecords(
-	ctx context.Context, config common.ReadParams, jsonPath string,
-) common.RecordsFunc {
-	return func(node *ajson.Node) ([]map[string]any, error) {
-		arr, err := jsonquery.New(node).ArrayOptional(jsonPath)
-		if err != nil {
-			return nil, err
-		}
-
-		customFields, err := c.requestCustomFields(ctx, config.ObjectName)
-		if err != nil {
-			return nil, err
-		}
-
+func (c *Connector) attachReadCustomFields(customFields map[int]modelCustomField) common.RecordTransformer {
+	return func(node *ajson.Node) (map[string]any, error) {
 		if len(customFields) == 0 {
-			return jsonquery.Convertor.ArrayToMap(arr)
+			// No custom fields, no-op, return as is.
+			return jsonquery.Convertor.ObjectToMap(node)
 		}
 
-		return enhanceObjectsWithCustomFieldNames(arr, customFields)
+		return enhanceObjectsWithCustomFieldNames(node, customFields)
 	}
 }
 
@@ -49,31 +45,25 @@ func (c *Connector) parseReadRecords(
 // * Replace ids with human-readable names, which is provided as argument.
 // * Place fields at the top level of the object.
 func enhanceObjectsWithCustomFieldNames(
-	arr []*ajson.Node,
+	node *ajson.Node,
 	fields map[int]modelCustomField,
-) ([]map[string]any, error) {
-	result := make([]map[string]any, len(arr))
-
-	for index, node := range arr {
-		object, err := jsonquery.Convertor.ObjectToMap(node)
-		if err != nil {
-			return nil, err
-		}
+) (map[string]any, error) {
+	object, err := jsonquery.Convertor.ObjectToMap(node)
+	if err != nil {
+		return nil, err
+	}
 
-		customFieldsResponse, err := jsonquery.ParseNode[readCustomFieldsResponse](node)
-		if err != nil {
-			return nil, err
-		}
+	customFieldsResponse, err := jsonquery.ParseNode[readCustomFieldsResponse](node)
+	if err != nil {
+		return nil, err
+	}
 
-		// Replace identifiers with human-readable field names which were found by making a call to "/model".
-		for _, field := range customFieldsResponse.CustomFields {
-			if model, ok := fields[field.ID]; ok {
-				object[model.FieldName] = field.Content
-			}
+	// Replace identifiers with human-readable field names which were found by making a call to "/model".
+	for _, field := range customFieldsResponse.CustomFields {
+		if model, ok := fields[field.ID]; ok {
+			object[model.FieldName] = field.Content
 		}
-
-		result[index] = object
 	}
 
-	return result, nil
+	return object, nil
 }
diff --git a/providers/keap/read.go b/providers/keap/read.go
index 114282ed..60c6a2ad 100644
--- a/providers/keap/read.go
+++ b/providers/keap/read.go
@@ -39,12 +39,15 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		return nil, err
 	}
 
-	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
+	customFields, err := c.requestCustomFields(ctx, config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
 
 	return common.ParseResult(res,
-		c.parseReadRecords(ctx, config, responseFieldName),
+		makeGetRecords(c.Module.ID, config.ObjectName),
 		makeNextRecordsURL(c.Module.ID),
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(c.attachReadCustomFields(customFields)),
 		config.Fields,
 	)
 }
diff --git a/providers/kit/read.go b/providers/kit/read.go
index 93ed8439..919e9779 100644
--- a/providers/kit/read.go
+++ b/providers/kit/read.go
@@ -29,7 +29,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(config.ObjectName),
+		common.ExtractRecordsFromPath(config.ObjectName),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/klaviyo/parse.go b/providers/klaviyo/parse.go
index d7d8f82a..5948ef90 100644
--- a/providers/klaviyo/parse.go
+++ b/providers/klaviyo/parse.go
@@ -5,67 +5,8 @@ import (
 	"github.com/spyzhov/ajson"
 )
 
-// Every object has special field attributes which holds all the object specific fields.
-// Therefore, nested "attributes" will be removed and fields will be moved to the top level of the object.
-//
-// Example accounts(shortened response):
-//
-//	 "data": [
-//	    {
-//	        "type": "",
-//	        "id": "",
-//	        "attributes": {
-//	            "test_account": false,
-//	            "contact_information": {},
-//	            "locale": ""
-//	        },
-//	        "links": {}
-//	    }
-//	],
-//
-// The resulting fields for the above will be: type, id, test_account, contact_information, locale, links.
-func getRecords(node *ajson.Node) ([]map[string]any, error) {
-	arr, err := jsonquery.New(node).ArrayOptional("data")
-	if err != nil {
-		return nil, err
-	}
-
-	return flattenRecords(arr)
-}
-
-func flattenRecords(arr []*ajson.Node) ([]map[string]any, error) {
-	result := make([]map[string]any, len(arr))
-
-	for index, element := range arr {
-		const keyAttributes = "attributes"
-
-		attributes, err := jsonquery.New(element).ObjectOptional(keyAttributes)
-		if err != nil {
-			return nil, err
-		}
-
-		original, err := jsonquery.Convertor.ObjectToMap(element)
-		if err != nil {
-			return nil, err
-		}
-
-		nested, err := jsonquery.Convertor.ObjectToMap(attributes)
-		if err != nil {
-			return nil, err
-		}
-
-		// Attributes object must be removed.
-		delete(original, keyAttributes)
-
-		// Fields from attributes are moved to the top level.
-		for key, value := range nested {
-			original[key] = value
-		}
-
-		result[index] = original
-	}
-
-	return result, nil
+func getRecords(node *ajson.Node) ([]*ajson.Node, error) {
+	return jsonquery.New(node).ArrayOptional("data")
 }
 
 func getNextRecordsURL(node *ajson.Node) (string, error) {
diff --git a/providers/klaviyo/read.go b/providers/klaviyo/read.go
index cdbad278..77a88b51 100644
--- a/providers/klaviyo/read.go
+++ b/providers/klaviyo/read.go
@@ -31,7 +31,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	return common.ParseResult(res,
 		getRecords,
 		getNextRecordsURL,
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(common.FlattenNestedFields("attributes")),
 		config.Fields,
 	)
 }
diff --git a/providers/lemlist/parse.go b/providers/lemlist/parse.go
index aea592e3..2c6655a9 100644
--- a/providers/lemlist/parse.go
+++ b/providers/lemlist/parse.go
@@ -28,7 +28,7 @@ func records(objectName string) common.RecordsFunc {
 			return []map[string]any{record}, err
 
 		default:
-			return common.GetRecordsUnderJSONPath(fld)(node)
+			return common.ExtractRecordsFromPath(fld)(node)
 		}
 	}
 }
diff --git a/providers/pipedrive/read.go b/providers/pipedrive/read.go
index dde6191d..bc80baa1 100644
--- a/providers/pipedrive/read.go
+++ b/providers/pipedrive/read.go
@@ -26,7 +26,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	}
 
 	return common.ParseResult(resp,
-		common.GetOptionalRecordsUnderJSONPath("data"),
+		common.ExtractOptionalRecordsFromPath("data"),
 		nextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/podium/handlers.go b/providers/podium/handlers.go
index 91799479..fec8fd40 100644
--- a/providers/podium/handlers.go
+++ b/providers/podium/handlers.go
@@ -97,7 +97,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetOptionalRecordsUnderJSONPath(dataField),
+		common.ExtractOptionalRecordsFromPath(dataField),
 		nextRecordsURL(),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/smartleadv2/handlers.go b/providers/smartleadv2/handlers.go
index bc9fd388..18e58247 100644
--- a/providers/smartleadv2/handlers.go
+++ b/providers/smartleadv2/handlers.go
@@ -30,7 +30,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		resp,
-		common.GetOptionalRecordsUnderJSONPath(""),
+		common.ExtractOptionalRecordsFromPath(""),
 		getNextRecordsURL,
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/stripe/read.go b/providers/stripe/read.go
index 12d929c4..0eee9fae 100644
--- a/providers/stripe/read.go
+++ b/providers/stripe/read.go
@@ -38,7 +38,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
 
 	return common.ParseResult(res,
-		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		common.ExtractOptionalRecordsFromPath(responseFieldName),
 		makeNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/zendeskchat/handlers.go b/providers/zendeskchat/handlers.go
index 407e6343..97845f47 100644
--- a/providers/zendeskchat/handlers.go
+++ b/providers/zendeskchat/handlers.go
@@ -139,7 +139,7 @@ func (c *Connector) parseReadResponse(
 ) (*common.ReadResult, error) {
 	return common.ParseResult(
 		response,
-		common.GetRecordsUnderJSONPath(responseField(params.ObjectName)),
+		common.ExtractRecordsFromPath(responseField(params.ObjectName)),
 		nextRecordsURL(params.ObjectName),
 		common.GetMarshaledData,
 		params.Fields,
diff --git a/providers/zendesksupport/read.go b/providers/zendesksupport/read.go
index d14b5d60..ccc12151 100644
--- a/providers/zendesksupport/read.go
+++ b/providers/zendesksupport/read.go
@@ -33,7 +33,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		getNextRecordsURL,
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/zohocrm/read.go b/providers/zohocrm/read.go
index 59e4a54c..9de0e348 100644
--- a/providers/zohocrm/read.go
+++ b/providers/zohocrm/read.go
@@ -27,7 +27,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 	}
 
 	return common.ParseResult(res,
-		common.GetRecordsUnderJSONPath("data"),
+		common.ExtractRecordsFromPath("data"),
 		getNextRecordsURL(url),
 		common.GetMarshaledData,
 		config.Fields,
diff --git a/providers/zoom/read.go b/providers/zoom/read.go
index 599dcc2d..3e19f3dc 100644
--- a/providers/zoom/read.go
+++ b/providers/zoom/read.go
@@ -32,7 +32,7 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 
 	return common.ParseResult(
 		rsp,
-		common.GetRecordsUnderJSONPath(responseFieldName),
+		common.ExtractRecordsFromPath(responseFieldName),
 		getNextRecordURL(url),
 		common.GetMarshaledData,
 		config.Fields,


Test patch:
diff --git a/providers/atlassian/read_test.go b/providers/atlassian/read_test.go
index 5c060cf4..98ca067b 100644
--- a/providers/atlassian/read_test.go
+++ b/providers/atlassian/read_test.go
@@ -228,10 +228,8 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"summary": "Another one",
 					},
 					Raw: map[string]any{
-						"id":                       "10001",
-						"description":              nil,
-						"created":                  "2024-07-22T22:41:48.474+0300",
-						"statuscategorychangedate": "2024-07-22T22:41:48.686+0300",
+						"id":  "10001",
+						"key": "AM-2",
 					},
 				}, {
 					Fields: map[string]any{
@@ -239,10 +237,8 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"summary": "First Issue on Jira",
 					},
 					Raw: map[string]any{
-						"id":                       "10000",
-						"description":              nil,
-						"created":                  "2024-07-22T22:41:35.069+0300",
-						"statuscategorychangedate": "2024-07-22T22:41:35.326+0300",
+						"id":  "10000",
+						"key": "AM-1",
 					},
 				}},
 				NextPage: "2",
diff --git a/providers/keap/read_test.go b/providers/keap/read_test.go
index c295a6fc..1e7bdd82 100644
--- a/providers/keap/read_test.go
+++ b/providers/keap/read_test.go
@@ -75,7 +75,11 @@ func TestReadV1(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 			Name: "Contacts first page has a link to next",
 			Input: common.ReadParams{
 				ObjectName: "contacts",
-				Fields:     connectors.Fields("given_name", "jobtitle"),
+				Fields: connectors.Fields("given_name",
+					// Next fields are custom fields which do NOT exist inside raw.
+					// However, they are surfaced to the user via ListObjectMetadata,
+					// so they will have context to request them.
+					"jobtitle", "jobdescription", "experience", "age"),
 			},
 			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
@@ -92,15 +96,15 @@ func TestReadV1(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 				Rows: 2,
 				Data: []common.ReadResultRow{{
 					Fields: map[string]any{
-						"given_name": "Erica",
-						"jobtitle":   "Product Owner",
-					},
-					Raw: map[string]any{
-						"id":             float64(22),
-						"family_name":    "Lewis",
+						"given_name":     "Erica",
+						"jobtitle":       "Product Owner",
 						"jobdescription": "AI application in commerce",
 						"experience":     "8 years in 3 companies",
 						"age":            float64(32),
+					},
+					Raw: map[string]any{
+						"id":          float64(22),
+						"family_name": "Lewis",
 						"custom_fields": []any{map[string]any{
 							"id":      float64(12),
 							"content": "8 years in 3 companies",
@@ -117,16 +121,16 @@ func TestReadV1(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 					},
 				}, {
 					Fields: map[string]any{
-						"given_name": "John",
-						"jobtitle":   nil,
-					},
-					Raw: map[string]any{
-						"id":             float64(20),
-						"family_name":    "Doe",
+						"given_name":     "John",
+						"jobtitle":       nil,
 						"jobdescription": nil,
 						"experience":     nil,
 						"age":            nil,
 					},
+					Raw: map[string]any{
+						"id":          float64(20),
+						"family_name": "Doe",
+					},
 				}},
 				NextPage: "https://api.infusionsoft.com/crm/rest/v1/contacts/?limit=2&offset=2&since=2024-06-03T22:17:59.039Z&order=id", // nolint:lll
 				Done:     false,
diff --git a/providers/klaviyo/read_test.go b/providers/klaviyo/read_test.go
index b7c0a34e..363cbd2e 100644
--- a/providers/klaviyo/read_test.go
+++ b/providers/klaviyo/read_test.go
@@ -75,9 +75,24 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"email": "jennifer@gmail.com",
 					},
 					Raw: map[string]any{
-						"id":      "01HSXWNWF52J5PJG45BW383RMV",
-						"created": "2024-03-26T17:24:42+00:00",
-						"updated": "2024-03-26T17:24:42+00:00",
+						"id": "01HSXWNWF52J5PJG45BW383RMV",
+						"attributes": map[string]any{
+							"email":           "jennifer@gmail.com",
+							"phone_number":    nil,
+							"external_id":     nil,
+							"anonymous_id":    nil,
+							"first_name":      nil,
+							"last_name":       nil,
+							"organization":    nil,
+							"locale":          nil,
+							"title":           nil,
+							"image":           nil,
+							"created":         "2024-03-26T17:24:42+00:00",
+							"updated":         "2024-03-26T17:24:42+00:00",
+							"last_event_date": "2024-03-26T17:24:41+00:00",
+							"location":        map[string]any{},
+							"properties":      map[string]any{},
+						},
 					},
 				}},
 				NextPage: "https://a.klaviyo.com/api/profiles?page%5Bsize%5D=1&page%5Bcursor%5D=bmV4dDo6aWQ6OjAxSFNYV05XRjUySjVQSkc0NUJXMzgzUk1W", // nolint:lll
@@ -110,9 +125,19 @@ func TestRead(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
 						"name": "Email Campaign - Nov 15, 2024, 1:18 AM",
 					},
 					Raw: map[string]any{
-						"status":       "Scheduled",
-						"created_at":   "2024-11-14T23:18:34.827140+00:00",
-						"scheduled_at": "2024-11-14T23:20:02.718919+00:00",
+						"attributes": map[string]any{
+							"name":             "Email Campaign - Nov 15, 2024, 1:18 AM",
+							"status":           "Scheduled",
+							"archived":         false,
+							"audiences":        map[string]any{},
+							"send_options":     map[string]any{},
+							"tracking_options": map[string]any{},
+							"send_strategy":    map[string]any{},
+							"created_at":       "2024-11-14T23:18:34.827140+00:00",
+							"scheduled_at":     "2024-11-14T23:20:02.718919+00:00",
+							"updated_at":       "2024-11-14T23:20:32.232276+00:00",
+							"send_time":        "2024-11-30T18:15:00+00:00",
+						},
 					},
 				}},
 				NextPage: "",
diff --git a/providers/klaviyo/test/read-campaigns.json b/providers/klaviyo/test/read-campaigns.json
index c955b047..976f037f 100644
--- a/providers/klaviyo/test/read-campaigns.json
+++ b/providers/klaviyo/test/read-campaigns.json
@@ -1,65 +1,49 @@
 {
-  "data" : [ {
-    "type" : "campaign",
-    "id" : "01JCPFHB29QZ1NDPR3GCGQS5G2",
-    "attributes" : {
-      "name" : "Email Campaign - Nov 15, 2024, 1:18 AM",
-      "status" : "Scheduled",
-      "archived" : false,
-      "audiences" : {
-        "included" : [ "XzKvNz" ],
-        "excluded" : [ ]
+  "data": [
+    {
+      "type": "campaign",
+      "id": "01JCPFHB29QZ1NDPR3GCGQS5G2",
+      "attributes": {
+        "name": "Email Campaign - Nov 15, 2024, 1:18 AM",
+        "status": "Scheduled",
+        "archived": false,
+        "audiences": {},
+        "send_options": {},
+        "tracking_options": {},
+        "send_strategy": {},
+        "created_at": "2024-11-14T23:18:34.827140+00:00",
+        "scheduled_at": "2024-11-14T23:20:02.718919+00:00",
+        "updated_at": "2024-11-14T23:20:32.232276+00:00",
+        "send_time": "2024-11-30T18:15:00+00:00"
       },
-      "send_options" : {
-        "use_smart_sending" : true,
-        "ignore_unsubscribes" : false
-      },
-      "tracking_options" : {
-        "add_tracking_params" : false,
-        "custom_tracking_params" : [ ],
-        "is_tracking_clicks" : true,
-        "is_tracking_opens" : true
-      },
-      "send_strategy" : {
-        "method" : "static",
-        "options_static" : {
-          "datetime" : "2024-11-30T18:15:00+00:00",
-          "is_local" : true,
-          "send_past_recipients_immediately" : true
+      "relationships": {
+        "campaign-messages": {
+          "data": [
+            {
+              "type": "campaign-message",
+              "id": "01JCPFHB2F7J0J2R2HC8GNNC3Z"
+            }
+          ],
+          "links": {
+            "self": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/campaign-messages/",
+            "related": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/campaign-messages/"
+          }
         },
-        "options_throttled" : null,
-        "options_sto" : null
-      },
-      "created_at" : "2024-11-14T23:18:34.827140+00:00",
-      "scheduled_at" : "2024-11-14T23:20:02.718919+00:00",
-      "updated_at" : "2024-11-14T23:20:32.232276+00:00",
-      "send_time" : "2024-11-30T18:15:00+00:00"
-    },
-    "relationships" : {
-      "campaign-messages" : {
-        "data" : [ {
-          "type" : "campaign-message",
-          "id" : "01JCPFHB2F7J0J2R2HC8GNNC3Z"
-        } ],
-        "links" : {
-          "self" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/campaign-messages/",
-          "related" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/campaign-messages/"
+        "tags": {
+          "links": {
+            "self": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/tags/",
+            "related": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/tags/"
+          }
         }
       },
-      "tags" : {
-        "links" : {
-          "self" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/relationships/tags/",
-          "related" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/tags/"
-        }
+      "links": {
+        "self": "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/"
       }
-    },
-    "links" : {
-      "self" : "https://a.klaviyo.com/api/campaigns/01JCPFHB29QZ1NDPR3GCGQS5G2/"
     }
-  } ],
-  "links" : {
-    "self" : "https://a.klaviyo.com/api/campaigns?filter=greater-than%28updated_at%2C2024-11-14T22%3A27%3A51Z%29%2Cequals%28messages.channel%2C%27email%27%29",
-    "next" : null,
-    "prev" : null
+  ],
+  "links": {
+    "self": "https://a.klaviyo.com/api/campaigns?filter=greater-than%28updated_at%2C2024-11-14T22%3A27%3A51Z%29%2Cequals%28messages.channel%2C%27email%27%29",
+    "next": null,
+    "prev": null
   }
 }
diff --git a/providers/klaviyo/test/read-profiles-1-first-page.json b/providers/klaviyo/test/read-profiles-1-first-page.json
index 70697307..6134e344 100644
--- a/providers/klaviyo/test/read-profiles-1-first-page.json
+++ b/providers/klaviyo/test/read-profiles-1-first-page.json
@@ -17,18 +17,7 @@
         "created": "2024-03-26T17:24:42+00:00",
         "updated": "2024-03-26T17:24:42+00:00",
         "last_event_date": "2024-03-26T17:24:41+00:00",
-        "location": {
-          "city": null,
-          "zip": null,
-          "country": null,
-          "longitude": null,
-          "address2": null,
-          "address1": null,
-          "region": null,
-          "latitude": null,
-          "timezone": null,
-          "ip": null
-        },
+        "location": {},
         "properties": {}
       },
       "relationships": {
diff --git a/test/atlassian/connector.go b/test/atlassian/connector.go
index b65aaabd..9dc6fb32 100644
--- a/test/atlassian/connector.go
+++ b/test/atlassian/connector.go
@@ -12,7 +12,7 @@ import (
 	"golang.org/x/oauth2"
 )
 
-const cloudId = "ebc887b2-7e61-4059-ab35-71f15cc16e12"
+const cloudId = "35745fff-f0de-466c-b08e-a63f69888611"
 
 func GetAtlassianConnector(ctx context.Context) *atlassian.Connector {
 	filePath := credscanning.LoadPath(providers.Atlassian)
@@ -25,6 +25,7 @@ func GetAtlassianConnector(ctx context.Context) *atlassian.Connector {
 		atlassian.WithMetadata(map[string]string{
 			// This value can be obtained by following this API reference.
 			// https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/#3-1-get-the-cloudid-for-your-site
+			// Another simplest solution is to run `connectors/test/atlassian/auth-metadata/main.go` script.
 			"cloudId": cloudId,
 		}),
 	)
diff --git a/test/atlassian/read/main.go b/test/atlassian/read/main.go
index a56f7e55..acf44a3a 100644
--- a/test/atlassian/read/main.go
+++ b/test/atlassian/read/main.go
@@ -24,7 +24,8 @@ func main() {
 	conn := connTest.GetAtlassianConnector(ctx)
 
 	res, err := conn.Read(ctx, common.ReadParams{
-		Fields: connectors.Fields("id", "summary", "status"),
+		ObjectName: "issues",
+		Fields:     connectors.Fields("id", "summary", "status"),
 		// Below is the example to get issues that were updated in the last 15 min.
 		// Since: time.Now().Add(-15 * time.Minute),
 	})
diff --git a/test/keap/read/main.go b/test/keap/read/main.go
index aad2610e..5a2af0bd 100644
--- a/test/keap/read/main.go
+++ b/test/keap/read/main.go
@@ -26,7 +26,7 @@ func main() {
 
 	res, err := conn.Read(ctx, common.ReadParams{
 		ObjectName: "contacts",
-		Fields:     connectors.Fields("id"),
+		Fields:     connectors.Fields("id", "experience"),
 		// Since:      time.Now().Add(-30 * time.Minute),
 		// NextPage: "https://api.infusionsoft.com/crm/rest/v1/contacts/?limit=1&offset=50&since=2024-12-17T21:39:36.099Z&order=id",
 	})
@@ -34,6 +34,6 @@ func main() {
 		utils.Fail("error reading from Keap", "error", err)
 	}
 
-	fmt.Println("Reading emails..")
+	fmt.Println("Reading contacts..")
 	utils.DumpJSON(res, os.Stdout)
 }