Repository: amp-labs/connectors
Pull request #1094 (2024-10-22 22:14:07)

feat: CustomerJourneysApp (Read)
# Description
This is a read implementation for `Customer Journeys App` connector.

For simplicity the code that extracts metadata schema is kept seperate (will be part of ListObjectMetadata).
The schema.json file is commited and used for read. This is a complete list of supported objects for reading.

The tests includes sample empty response for exports and newletters (I went over all objects and there seems to be 2 common patterns for empty response, `null` and `[]` lists).

Next page URL is built using "next" response field with correct URL encoding. The page size various and a minimum of 50 entries has been chosen.

# Manual Test Output

![image](https://github.com/user-attachments/assets/13053e42-b6d5-4baf-92f1-80ab1fd8d661)
![image](https://github.com/user-attachments/assets/1369801b-bf10-460c-8947-7880b610bc94)


Cobalt0s: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1094?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#1094** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1094?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* **#1078** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1078?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#1067](https://github.com/amp-labs/connectors/pull/1067) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1067?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* **#1077** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1077?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`

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

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

Patch:
diff --git a/common/parse.go b/common/parse.go
index ed21752f..5231f8fd 100644
--- a/common/parse.go
+++ b/common/parse.go
@@ -106,3 +106,14 @@ func GetRecordsUnderJSONPath(jsonPath string) RecordsFunc {
 		return jsonquery.Convertor.ArrayToMap(arr)
 	}
 }
+
+func GetOptionalRecordsUnderJSONPath(jsonPath string) RecordsFunc {
+	return func(node *ajson.Node) ([]map[string]any, error) {
+		arr, err := jsonquery.New(node).Array(jsonPath, true)
+		if err != nil {
+			return nil, err
+		}
+
+		return jsonquery.Convertor.ArrayToMap(arr)
+	}
+}
diff --git a/common/scanning/credscanning/path.go b/common/scanning/credscanning/path.go
index 318aee6f..fa2a67aa 100644
--- a/common/scanning/credscanning/path.go
+++ b/common/scanning/credscanning/path.go
@@ -2,6 +2,7 @@ package credscanning
 
 import (
 	"fmt"
+	"log/slog"
 	"os"
 
 	"github.com/iancoleman/strcase"
@@ -20,5 +21,7 @@ func LoadPath(providerName string) string {
 		filePath = fmt.Sprintf("./%v-creds.json", filePath)
 	}
 
+	slog.Debug("loading credentials file", "path", filePath)
+
 	return filePath
 }
diff --git a/providers/customerapp/client.go b/providers/customerapp/client.go
new file mode 100644
index 00000000..58083776
--- /dev/null
+++ b/providers/customerapp/client.go
@@ -0,0 +1,18 @@
+package customerapp
+
+import (
+	"github.com/amp-labs/connectors/common"
+)
+
+// JSONHTTPClient returns the underlying JSON HTTP client.
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.Client
+}
+
+func (c *Connector) HTTPClient() *common.HTTPClient {
+	return c.Client.HTTPClient
+}
+
+func (c *Connector) Close() error {
+	return nil
+}
diff --git a/providers/customerapp/connector.go b/providers/customerapp/connector.go
new file mode 100644
index 00000000..57829913
--- /dev/null
+++ b/providers/customerapp/connector.go
@@ -0,0 +1,67 @@
+package customerapp
+
+import (
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers"
+)
+
+const ApiVersion = "v1"
+
+type Connector struct {
+	BaseURL string
+	Client  *common.JSONHTTPClient
+	Module  common.Module
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
+
+	params, err := paramsbuilder.Apply(parameters{}, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	httpClient := params.Client.Caller
+	conn = &Connector{
+		Client: &common.JSONHTTPClient{
+			HTTPClient: httpClient,
+		},
+	}
+
+	// Read provider info
+	providerInfo, err := providers.ReadInfo(conn.Provider())
+	if err != nil {
+		return nil, err
+	}
+
+	// connector and its client must mirror base url and provide its own error parser
+	conn.setBaseURL(providerInfo.BaseURL)
+	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
+		JSON: interpreter.NewFaultyResponder(errorFormats, nil),
+	}.Handle
+
+	return conn, nil
+}
+
+func (c *Connector) getURL(arg string) (*urlbuilder.URL, error) {
+	return urlbuilder.New(c.BaseURL, ApiVersion, arg)
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
+
+func (c *Connector) Provider() providers.Provider {
+	return providers.CustomerJourneysApp
+}
+
+func (c *Connector) String() string {
+	return c.Provider() + ".Connector"
+}
diff --git a/providers/customerapp/errors.go b/providers/customerapp/errors.go
new file mode 100644
index 00000000..c863cd3c
--- /dev/null
+++ b/providers/customerapp/errors.go
@@ -0,0 +1,39 @@
+package customerapp
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+var errorFormats = interpreter.NewFormatSwitch( // nolint:gochecknoglobals
+	[]interpreter.FormatTemplate{
+		{
+			MustKeys: nil,
+			Template: func() interpreter.ErrorDescriptor { return &ResponseError{} },
+		},
+	}...,
+)
+
+type ResponseError struct {
+	Errors []ErrorDetails `json:"errors"`
+}
+
+type ErrorDetails struct {
+	Detail string `json:"detail"`
+	Status string `json:"status"`
+}
+
+func (r ResponseError) CombineErr(base error) error {
+	if len(r.Errors) == 0 {
+		return base
+	}
+
+	details := make([]string, len(r.Errors))
+	for i, obj := range r.Errors {
+		details[i] = obj.Detail
+	}
+
+	return fmt.Errorf("%w: %v", base, strings.Join(details, ", "))
+}
diff --git a/providers/customerapp/metadata/schemas.json b/providers/customerapp/metadata/schemas.json
new file mode 100755
index 00000000..650597c5
--- /dev/null
+++ b/providers/customerapp/metadata/schemas.json
@@ -0,0 +1,217 @@
+{
+  "modules": {
+    "root": {
+      "id": "root",
+      "path": "/v1",
+      "objects": {
+        "activities": {
+          "displayName": "Activities",
+          "path": "/activities",
+          "fields": {
+            "customer_id": "customer_id",
+            "customer_identifiers": "customer_identifiers",
+            "data": "data",
+            "delivery_id": "delivery_id",
+            "delivery_type": "delivery_type",
+            "id": "id",
+            "timestamp": "timestamp",
+            "type": "type"
+          }
+        },
+        "broadcasts": {
+          "displayName": "Broadcasts",
+          "path": "/broadcasts",
+          "fields": {
+            "actions": "actions",
+            "active": "active",
+            "created": "created",
+            "deduplicate_id": "deduplicate_id",
+            "first_started": "first_started",
+            "id": "id",
+            "msg_template_ids": "msg_template_ids",
+            "name": "name",
+            "state": "state",
+            "tags": "tags",
+            "type": "type",
+            "updated": "updated"
+          }
+        },
+        "collections": {
+          "displayName": "Collections",
+          "path": "/collections",
+          "fields": {
+            "bytes": "bytes",
+            "created_at": "created_at",
+            "id": "id",
+            "name": "name",
+            "rows": "rows",
+            "schema": "schema",
+            "updated_at": "updated_at"
+          }
+        },
+        "exports": {
+          "displayName": "Exports",
+          "path": "/exports",
+          "fields": {
+            "created_at": "created_at",
+            "deduplicate_id": "deduplicate_id",
+            "description": "description",
+            "downloads": "downloads",
+            "failed": "failed",
+            "id": "id",
+            "status": "status",
+            "total": "total",
+            "type": "type",
+            "updated_at": "updated_at",
+            "user_email": "user_email",
+            "user_id": "user_id"
+          }
+        },
+        "messages": {
+          "displayName": "Messages",
+          "path": "/messages",
+          "fields": {
+            "action_id": "action_id",
+            "broadcast_id": "broadcast_id",
+            "campaign_id": "campaign_id",
+            "content_id": "content_id",
+            "created": "created",
+            "customer_id": "customer_id",
+            "customer_identifiers": "customer_identifiers",
+            "deduplicate_id": "deduplicate_id",
+            "failure_message": "failure_message",
+            "forgotten": "forgotten",
+            "id": "id",
+            "message_template_id": "message_template_id",
+            "metrics": "metrics",
+            "newsletter_id": "newsletter_id",
+            "parent_action_id": "parent_action_id",
+            "recipient": "recipient",
+            "subject": "subject",
+            "trigger_event_id": "trigger_event_id",
+            "type": "type"
+          }
+        },
+        "newsletters": {
+          "displayName": "Newsletters",
+          "path": "/newsletters",
+          "fields": {
+            "content_ids": "content_ids",
+            "created": "created",
+            "deduplicate_id": "deduplicate_id",
+            "id": "id",
+            "name": "name",
+            "recipient_segment_ids": "recipient_segment_ids",
+            "sent_at": "sent_at",
+            "subscription_topic_id": "subscription_topic_id",
+            "tags": "tags",
+            "type": "type",
+            "updated": "updated"
+          }
+        },
+        "object_types": {
+          "displayName": "Object Types",
+          "path": "/object_types",
+          "fields": {
+            "enabled": "enabled",
+            "icon": "icon",
+            "id": "id",
+            "name": "name",
+            "singular_name": "singular_name",
+            "singular_slug": "singular_slug",
+            "slug": "slug"
+          }
+        },
+        "reporting_webhooks": {
+          "displayName": "Reporting Webhooks",
+          "path": "/reporting_webhooks",
+          "fields": {
+            "disabled": "disabled",
+            "endpoint": "endpoint",
+            "events": "events",
+            "full_resolution": "full_resolution",
+            "id": "id",
+            "name": "name",
+            "with_content": "with_content"
+          }
+        },
+        "segments": {
+          "displayName": "Segments",
+          "path": "/segments",
+          "fields": {
+            "deduplicate_id": "deduplicate_id",
+            "description": "description",
+            "id": "id",
+            "name": "name",
+            "progress": "progress",
+            "state": "state",
+            "tags": "tags",
+            "type": "type"
+          }
+        },
+        "sender_identities": {
+          "displayName": "Sender Identities",
+          "path": "/sender_identities",
+          "fields": {
+            "address": "address",
+            "auto_generated": "auto_generated",
+            "deduplicate_id": "deduplicate_id",
+            "email": "email",
+            "id": "id",
+            "name": "name",
+            "template_type": "template_type"
+          }
+        },
+        "snippets": {
+          "displayName": "Snippets",
+          "path": "/snippets",
+          "fields": {
+            "name": "name",
+            "updated_at": "updated_at",
+            "value": "value"
+          }
+        },
+        "subscription_topics": {
+          "displayName": "Subscription Topics",
+          "path": "/subscription_topics",
+          "fields": {
+            "description": "description",
+            "id": "id",
+            "identifier": "identifier",
+            "name": "name",
+            "subscribed_by_default": "subscribed_by_default"
+          }
+        },
+        "transactional": {
+          "displayName": "Transactional",
+          "path": "/transactional",
+          "fields": {
+            "created_at": "created_at",
+            "description": "description",
+            "hide_message_body": "hide_message_body",
+            "id": "id",
+            "link_tracking": "link_tracking",
+            "name": "name",
+            "open_tracking": "open_tracking",
+            "queue_drafts": "queue_drafts",
+            "send_to_unsubscribed": "send_to_unsubscribed",
+            "updated_at": "updated_at"
+          }
+        },
+        "workspaces": {
+          "displayName": "Workspaces",
+          "path": "/workspaces",
+          "fields": {
+            "billable_messages_sent": "billable_messages_sent",
+            "id": "id",
+            "messages_sent": "messages_sent",
+            "name": "name",
+            "object_types": "object_types",
+            "objects": "objects",
+            "people": "people"
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/customerapp/metadata/scraping.go b/providers/customerapp/metadata/scraping.go
new file mode 100644
index 00000000..8e928f4b
--- /dev/null
+++ b/providers/customerapp/metadata/scraping.go
@@ -0,0 +1,20 @@
+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/customerapp/objectNames.go b/providers/customerapp/objectNames.go
new file mode 100644
index 00000000..287998c0
--- /dev/null
+++ b/providers/customerapp/objectNames.go
@@ -0,0 +1,20 @@
+package customerapp
+
+import (
+	"github.com/amp-labs/connectors/common/handy"
+	"github.com/amp-labs/connectors/providers/customerapp/metadata"
+)
+
+// Supported object names can be found under schemas.json.
+var supportedObjectsByRead = metadata.Schemas.ObjectNames() //nolint:gochecknoglobals
+
+// ObjectNameToResponseField maps ObjectName to the response field name which contains that object.
+var ObjectNameToResponseField = handy.NewDefaultMap(map[string]string{ //nolint:gochecknoglobals
+	"object_types":        "types",
+	"transactional":       "messages",
+	"subscription_topics": "topics",
+},
+	func(key string) string {
+		return key
+	},
+)
diff --git a/providers/customerapp/params.go b/providers/customerapp/params.go
new file mode 100644
index 00000000..f1ddca49
--- /dev/null
+++ b/providers/customerapp/params.go
@@ -0,0 +1,41 @@
+package customerapp
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"github.com/amp-labs/connectors/providers"
+)
+
+// DefaultPageSize is number of elements per page.
+const DefaultPageSize = 50
+
+// Option is a function which mutates the connector configuration.
+type Option = func(params *parameters)
+
+type parameters struct {
+	paramsbuilder.Client
+}
+
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+	)
+}
+
+func WithClient(ctx context.Context, client *http.Client,
+	apiKey string, opts ...common.HeaderAuthClientOption,
+) Option {
+	return func(params *parameters) {
+		params.WithApiKeyHeaderClient(ctx, client, providers.CustomerJourneysApp, apiKey, opts...)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
diff --git a/providers/customerapp/parse.go b/providers/customerapp/parse.go
new file mode 100644
index 00000000..cbf97fc6
--- /dev/null
+++ b/providers/customerapp/parse.go
@@ -0,0 +1,35 @@
+package customerapp
+
+import (
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/spyzhov/ajson"
+)
+
+func makeNextRecordsURL(reqLink *urlbuilder.URL) common.NextPageFunc {
+	return func(node *ajson.Node) (string, error) {
+		next, err := jsonquery.New(node).StrWithDefault("next", "")
+		if err != nil {
+			return "", err
+		}
+
+		if len(next) == 0 {
+			// Next page doesn't exist
+			return "", nil
+		}
+
+		// Next page token is base64 encoded,
+		// therefore it may contain symbols that should be exempt from escaping.
+		reqLink.AddEncodingExceptions(map[string]string{
+			"%3D": "=",
+		})
+
+		reqLink.WithQueryParam("start", next)
+		reqLink.WithQueryParam("limit", strconv.Itoa(DefaultPageSize))
+
+		return reqLink.String(), nil
+	}
+}
diff --git a/providers/customerapp/read.go b/providers/customerapp/read.go
new file mode 100644
index 00000000..4d13536b
--- /dev/null
+++ b/providers/customerapp/read.go
@@ -0,0 +1,36 @@
+package customerapp
+
+import (
+	"context"
+
+	"github.com/amp-labs/connectors/common"
+)
+
+func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
+	if err := config.ValidateParams(true); err != nil {
+		return nil, err
+	}
+
+	if !supportedObjectsByRead[c.Module.ID].Has(config.ObjectName) {
+		return nil, common.ErrOperationNotSupportedForObject
+	}
+
+	url, err := c.getURL(config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
+
+	res, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, err
+	}
+
+	responseFieldName := ObjectNameToResponseField.Get(config.ObjectName)
+
+	return common.ParseResult(res,
+		common.GetOptionalRecordsUnderJSONPath(responseFieldName),
+		makeNextRecordsURL(url),
+		common.GetMarshaledData,
+		config.Fields,
+	)
+}


Test patch:
diff --git a/providers/customerapp/read_test.go b/providers/customerapp/read_test.go
new file mode 100644
index 00000000..f78c981d
--- /dev/null
+++ b/providers/customerapp/read_test.go
@@ -0,0 +1,156 @@
+package customerapp
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+
+	"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/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,maintidx
+	t.Parallel()
+
+	responseExportsEmpty := testutils.DataFromFile(t, "read-exports-empty.json")
+	responseNewslettersFirstPage := testutils.DataFromFile(t, "read-newsletters-1-first-page.json")
+	responseNewslettersEmptyPage := testutils.DataFromFile(t, "read-newsletters-2-empty-page.json")
+
+	tests := []testroutines.Read{
+		{
+			Name:         "Read object must be included",
+			Input:        common.ReadParams{},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			Name:         "At least one field is requested",
+			Input:        common.ReadParams{ObjectName: "messages"},
+			Server:       mockserver.Dummy(),
+			ExpectedErrs: []error{common.ErrMissingFields},
+		},
+		{
+			Name:     "Unknown object name is not supported",
+			Input:    common.ReadParams{ObjectName: "orders", Fields: connectors.Fields("id")},
+			Server:   mockserver.Dummy(),
+			Expected: nil,
+			ExpectedErrs: []error{
+				common.ErrOperationNotSupportedForObject,
+			},
+		},
+		{
+			Name:  "Error response page not found",
+			Input: common.ReadParams{ObjectName: "messages", Fields: connectors.Fields("id")},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.ResponseString(http.StatusNotFound, "404 page not found"),
+			}.Server(),
+			ExpectedErrs: []error{
+				common.ErrBadRequest,
+			},
+		},
+		{
+			Name: "Newsletters first page has a link to next",
+			Input: common.ReadParams{
+				ObjectName: "newsletters",
+				Fields:     connectors.Fields("name"),
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, responseNewslettersFirstPage),
+			}.Server(),
+			Comparator: func(baseURL string, actual, expected *common.ReadResult) bool {
+				expectedNextPage := strings.ReplaceAll(expected.NextPage.String(), "{{testServerURL}}", baseURL)
+
+				return mockutils.ReadResultComparator.SubsetFields(actual, expected) &&
+					mockutils.ReadResultComparator.SubsetRaw(actual, expected) &&
+					actual.NextPage.String() == expectedNextPage &&
+					actual.Rows == expected.Rows &&
+					actual.Done == expected.Done
+			},
+			Expected: &common.ReadResult{
+				Rows: 1,
+				Data: []common.ReadResultRow{{
+					Fields: map[string]any{
+						"name": "Weekly Update #33",
+					},
+					Raw: map[string]any{
+						"deduplicate_id": "1:1724072800",
+						"type":           "email",
+						"sent_at":        nil,
+						"created":        float64(1724072465),
+					},
+				}},
+				NextPage: "{{testServerURL}}/v1/newsletters?limit=50&start=MQ==",
+				Done:     false,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read newsletters empty page",
+			Input: common.ReadParams{
+				ObjectName: "newsletters",
+				Fields:     connectors.Fields("name"),
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, responseNewslettersEmptyPage),
+			}.Server(),
+			Expected: &common.ReadResult{
+				Rows:     0,
+				Data:     []common.ReadResultRow{},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+		{
+			Name: "Read empty exports with null array",
+			Input: common.ReadParams{
+				ObjectName: "exports",
+				Fields:     connectors.Fields("id"),
+			},
+			Server: mockserver.Fixed{
+				Setup:  mockserver.ContentJSON(),
+				Always: mockserver.Response(http.StatusOK, responseExportsEmpty),
+			}.Server(),
+			Expected: &common.ReadResult{
+				Rows:     0,
+				Data:     []common.ReadResultRow{},
+				NextPage: "",
+				Done:     true,
+			},
+			ExpectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.Name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.Run(t, func() (connectors.ReadConnector, error) {
+				return constructTestConnector(tt.Server.URL)
+			})
+		})
+	}
+}
+
+func constructTestConnector(serverURL string) (*Connector, error) {
+	connector, err := NewConnector(
+		WithAuthenticatedClient(http.DefaultClient),
+	)
+	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/customerapp/test/read-exports-empty.json b/providers/customerapp/test/read-exports-empty.json
new file mode 100644
index 00000000..5d5fe58e
--- /dev/null
+++ b/providers/customerapp/test/read-exports-empty.json
@@ -0,0 +1,3 @@
+{
+  "exports": null
+}
diff --git a/providers/customerapp/test/read-newsletters-1-first-page.json b/providers/customerapp/test/read-newsletters-1-first-page.json
new file mode 100644
index 00000000..b356e1b4
--- /dev/null
+++ b/providers/customerapp/test/read-newsletters-1-first-page.json
@@ -0,0 +1,18 @@
+{
+  "newsletters": [
+    {
+      "id": 1,
+      "deduplicate_id": "1:1724072800",
+      "content_ids": [
+        2
+      ],
+      "name": "Weekly Update #33",
+      "sent_at": null,
+      "created": 1724072465,
+      "updated": 1724072800,
+      "type": "email",
+      "tags": []
+    }
+  ],
+  "next": "MQ=="
+}
diff --git a/providers/customerapp/test/read-newsletters-2-empty-page.json b/providers/customerapp/test/read-newsletters-2-empty-page.json
new file mode 100644
index 00000000..d2d72f72
--- /dev/null
+++ b/providers/customerapp/test/read-newsletters-2-empty-page.json
@@ -0,0 +1,4 @@
+{
+  "newsletters": [],
+  "next": ""
+}
diff --git a/test/customerio/journeysapp/connector.go b/test/customerio/journeysapp/connector.go
new file mode 100644
index 00000000..cfe39729
--- /dev/null
+++ b/test/customerio/journeysapp/connector.go
@@ -0,0 +1,27 @@
+package journeysapp
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/providers/customerapp"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func GetCustomerJourneysAppConnector(ctx context.Context) *customerapp.Connector {
+	filePath := credscanning.LoadPath(providers.CustomerJourneysApp)
+	reader := utils.MustCreateProvCredJSON(filePath, false, false)
+
+	conn, err := customerapp.NewConnector(
+		customerapp.WithClient(ctx, http.DefaultClient,
+			reader.Get(credscanning.Fields.ApiKey),
+		),
+	)
+	if err != nil {
+		utils.Fail("error creating Customer Journeys App connector", "error", err)
+	}
+
+	return conn
+}
diff --git a/test/customerio/journeysapp/read/main.go b/test/customerio/journeysapp/read/main.go
new file mode 100644
index 00000000..ae0e2c2c
--- /dev/null
+++ b/test/customerio/journeysapp/read/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/common"
+	connTest "github.com/amp-labs/connectors/test/customerio/journeysapp"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetCustomerJourneysAppConnector(ctx)
+	defer utils.Close(conn)
+
+	res, err := conn.Read(ctx, common.ReadParams{
+		ObjectName: "segments",
+		Fields:     connectors.Fields("name"),
+	})
+	if err != nil {
+		utils.Fail("error reading from Customer Journeys App", "error", err)
+	}
+
+	slog.Info("Reading segments..")
+	utils.DumpJSON(res, os.Stdout)
+}